🧪 第14章 实作物件侦测 API
📄 一句话概括

路由只做四件事:取文件、验类型、跑链路、返回 JSON。 错误也返回 JSON,这是 API 和网页最容易分叉的地方。 413 被拦在进入视图之前,得靠 app.errorhandler 补上。

📝 本章和前面几章的关系

这一篇对应教材 14.5、14.6 与 14.6.1:编写已学习模型的执行流程式码、实作路由建置、 确认运作情况。 上一篇把前后处理写好了,这一篇把它们接成一个端点。

一、路由只做四件事

f = request.files.get("file")
if f is None or not f.filename:
    return _err(400, "no file", "字段名必须是 file")
ext = f.filename.rsplit(".", 1)[-1].lower()
if ext not in current_app.config["ALLOWED"]:
    return _err(415, "unsupported media type", ext)

取文件、验类型、跑链路、返回 JSON。四件事,一行不多。 检测那五步全在 detector 里。路由不该知道图片怎么缩放、框怎么还原——它只负责「收进来、 交出去」。 这一条分工是第 14.1 那个目录结构的意义所在:views/detect.py 换个模型不用改, detector/ 换套接口不用改。

二、成功长什么样

实测 POST 一张 640x480 的图:

字段 值
状态码 200
count 2
size [640, 480]
tags[0] box [300,120,220,280]、label spot、score 0.647
tags[1] box [60,40,140,141]、label spot、score 0.411

跟第 13 章那张规格表对得上:200 带 count 与 tags,每条 label、score、box 四项。 size 这一项是给调用方算比例的——他知道原图多大,才能把框画在自己界面上。 这个字段看着多余,实际很省事。 响应体里 tags 按分数从高到低排。 顺序也是接口的一部分,调用方可能只取第一个,那就是「最可能的那个目标」。

三、三种错法,三种回答

情况 状态码 响应体
字段名写成 image 400 {"detail":"字段名必须是 file","error":"no file"}
传了 .txt 415 {"detail":"txt","error":"unsupported media type"}
传 800 KB(上限 512 KB) 413 HTML(还没改)

前两个是 JSON,第三个不是——这是这一篇最后要补的洞。 detail 那一项是刻意加的。字段名写错是最高频的接入问题,一句话省掉一轮来回邮件。 415 那个 detail 回的是实际扩展名。 调用方看到 txt 就知道自己传错了,而不是去看文档猜「到底支持什么格式」。

注意 415 是按扩展名判的,没读文件内容。 把假图改名成 .png 会进到读图那一步,imdecode 返回 None, 再返回「not a readable image」——还是 415。两道叠着才挡得住。

四、413 为什么漏了

上传上限由 MAX_CONTENT_LENGTH 控制,Flask 在处理请求体的时候就拦了, 根本走不到视图函数里那句「校验扩展名」。 所以视图里的 _err() 一点用都没有,得靠 app.errorhandler(413)。 实测传一个 800 KB 的文件(上限 512 KB),拿到的是第 10 章那个 HTML 错误页, 第一行是 <!doctype html>。

对一个 API 来说这是 bug:调用方按 JSON 解析,会报「解析失败」,把「文件太大, 上限 0.5 MB」这个真正的原因盖掉。 顺带一个反常识:同样是 4000x3000 的纯色 PNG,压缩之后只有几 KB,碰不到上限。 测「文件太大」要造真的字节,不是造大尺寸的图。

五、补上 413

@app.errorhandler(413)
def too_large(e):
    if request.path.startswith("/api"):
        return jsonify({"error": "payload too large"}), 413
    return "<h2>413 文件太大</h2>", 413

实测加上之后,同一个请求返回 {"error":"payload too large","limit_mb":0.5},状态码还是 413。 从一坨 HTML 变成一行 JSON,调用方那边从「解析失败」变成「文件超了,上限 0.5 MB」。 同一次失败,两种信息量。 按路径分流(startswith("/api"))比按 Accept 头分流简单,本课用前者。 同类要处理的还有 404、405、500。

API 的约定是「任何情况下响应体都是 JSON」,包括框架自己抛出来的那些错误。 这一条跟第 10 章那个 HTML 错误页不冲突——两套并存,按路径分。

六、对表验收

规格表上的行 实测
POST /detect 传图 200 + count 与 tags
没带文件 400 + JSON
不是图片 415 + JSON
太大 413(补了处理器之后是 JSON)
标签结构 label、score、box 四项

接口先定表、后写代码,验收就变成对表。 反过来先写代码的后果很具体:写着写着发现「这里少个字段」「那个错误码没定」, 于是每个人调的时候都要问一遍。 第 13 章那张表就是这么用的。它不是文档,是验收清单。

七、这一篇之后

第 3 篇的实作部分完了:一个能收图、能检测、能返回 JSON、出错也是 JSON 的 API。 但它还跑在自己电脑上,http://127.0.0.1:5000 只有自己能访问。 下一篇把它弄到公网上:打包成镜像、推到云端、变成一个别人能调的地址。

那一步会带出一个新问题:线上的环境和你本机不一样。Python 版本、装的包、系统库、时区, 任何一样不同都可能让它跑不起来。第 15 章开头那个 Docker 就是为这件事准备的。

八、动手 Demo:模型执行与路由:把它接成一个 API

这个 Demo 起一个真的 HTTP 服务,用真的 multipart 请求打它——不是 test_client。 第四、五两节是同一个请求的前后对比:先是 HTML,补了处理器之后是 JSON。

@bp.route("/detect", methods=["POST"])
def detect():
    f = request.files.get("file")
    if f is None or not f.filename:
        return _err(400, "no file", "字段名必须是 file")
    ext = f.filename.rsplit(".", 1)[-1].lower()
    if ext not in current_app.config["ALLOWED"]:
        return _err(415, "unsupported media type", ext)
    name = secure_filename(f.filename) or ("in." + ext)
    tmp = os.path.join(tempfile.mkdtemp(), name)
    f.save(tmp)
    img = detector.load_image(tmp)
    chw, src_wh = detector.preprocess(
        img, current_app.config["INPUT_SIZE"])
    tags = pp.postprocess(detector.infer(chw, img), src_wh,
                          current_app.config["INPUT_SIZE"],
                          current_app.config["CONF_THRESHOLD"],
                          current_app.config["NMS_IOU"])
    return jsonify({"count": len(tags), "size": list(src_wh),
                    "tags": tags})

实跑输出:

--------------------------------------------------------------------
模型执行与路由:把它接成一个 API
--------------------------------------------------------------------

  ① 正常一张
  --------------------------------------------------------------------
  POST /api/detect -> 200,71.0 ms
  响应体 -> {"count":2,"size":[640,480],"tags":[{"box":[300,120,220,280],"label":"spot","score":0.647},{"box":[60,40,140,141],"label":"spot","score":0.411}]}
  ! 这就是第 13 章那张规格表的样子。
  count 是几个目标,size 是原图尺寸,tags 里每条有 label、score、box 四项。

  ② 没带文件
  --------------------------------------------------------------------
  字段名写成 image -> 400
  响应体 -> {"detail":"字段名必须是 file","error":"no file"}
  ! 400,而且是 JSON。调用方拿到 error 就知道是「没给文件」,detail 还告诉你字段名该叫 file。
  ! 这个 detail 是刻意加的:字段名写错是最高频的接入问题,一句话省掉一轮来回。

  ③ 传了个不是图片的文件
  --------------------------------------------------------------------
  扩展名 .txt -> 415
  响应体 -> {"detail":"txt","error":"unsupported media type"}
  ! 415 Unsupported Media Type。注意它是按扩展名判的,没读文件内容。
  ! 把 broken.png 改名成 ok.png 再传,会进到读图那一步,imdecode 返回 None,
  然后返回「not a readable image」——还是 415。两道检查叠着,才能挡住改名的假图片。

  ④ 太大了
  --------------------------------------------------------------------
  传一个 800 KB 的文件(上限 512 KB)-> 413
  响应体第一行 -> <!doctype html>
  ! 这里返回的是 HTML,不是 JSON。因为 413 在进入视图之前就被拦了,走的是第 10 章那个全局错误页。
  ! 顺带一个反常识:同样是 4000x3000 的纯色 PNG,压缩之后只有几 KB,根本碰不到上限。
  所以测「文件太大」要造真的字节,不是造大尺寸的图。
  ! 这就是这一篇最后一个要补的洞:调用方拿到一坨 HTML,会报「JSON 解析失败」,
  把「文件太大」这个真正的原因盖掉。

  ⑤ 让 413 也返回 JSON
  --------------------------------------------------------------------

  [代码原样]
  | @app.errorhandler(413)
  | def too_large(e):
  |     if request.path.startswith("/api"):
  |         return jsonify({"error": "payload too large"}), 413
  |     return "<h2>413 文件太大</h2>", 413
  按路径分流:API 请求给 JSON,页面请求给 HTML。

  加了处理器之后,同一个请求 -> 413
  响应体 -> {"error":"payload too large","limit_mb":0.5}
  正常那张还是 -> 200(没被影响)
  ! 从一坨 HTML 变成一行 JSON,调用方那边从「解析失败」变成「文件超了,上限 0.5 MB」。
  同一次失败,两种信息量。
  ! 判断依据也可以换成 request.accept_mimetypes:客户端声明「我要 JSON」就给 JSON。
  路径前缀更简单可控,本课用这个。
  ! 同类要处理的还有 404、405、500。
  API 的约定是「任何情况下响应体都是 JSON」,包括框架自己抛出来的那些错误。
  ! 到这里,第 13 章那张规格表上每一行都对上了:200 带 count 与 tags、400 缺文件、415 类型不对、
  413 太大。接口定完再写代码,验收就变成对表。

第一节那条响应就是第 13 章规格表的成品。 第二、三节的 detail 是刻意设计的,减少接入时的来回。 第五节 413 从 HTML 变 JSON,状态码没变,信息量变了。

30 秒实验

  1. 把 _err 的 detail 去掉,再传一次 .txt,看调用方还能不能自己看出问题。
  2. 给 /api/detect 加一个 ?conf=0.8 查询参数,让调用方临时改阈值。
  3. 想一想:为什么 413 在视图里拦不住?

常见误区

  1. 在路由里写前处理:换模型要改路由。
  2. 错误返回 HTML:调用方解析不了,真正的原因被盖掉。
  3. 只在视图里校验上传大小:413 在进入视图之前就发生了。
  4. 413 不做处理:拿到一坨 HTML,以为是服务挂了。
自测三问(点开对答案) 1. 路由只做哪四件事? —— 取文件、验类型、跑链路、返回 JSON。 2. 为什么 413 在视图里拦不住? —— MAX_CONTENT_LENGTH 在解析请求体时就被检查,还没进到视图函数。 3. 为什么错误也要返回 JSON? —— 调用方按 JSON 解析,HTML 会让它报解析错误,把真正的原因盖掉。

第 14 章到此结束。下一篇把它打包成镜像,推到云端,变成别人能调的地址。

相关笔记