🧪 第8章 建立物件侦测功能
📄 一句话概括

端点四步:查图、验归属、跑检测、先清旧标签再插新的。 少一步「先清」,重复检测就会一直累加,一张图能堆出几百条标签。 检测是慢操作,同步做会让请求卡住——这是它跟前面那些端点最大的不同。

📝 本章和前面几章的关系

这一篇对应教材 8.4:建立物件侦测功能的端点。 上一篇备好了模型与检测库,这一篇把它们接进一个 POST 端点。

一、端点的顺序不能乱

@bp.route("/detect/<int:image_id>", methods=["POST"])
@login_required
def detect(image_id):
    img = db.session.get(UserImage, image_id)
    if img is None:
        abort(404)
    if img.user_id != current_user.id:
        abort(403)

四步:查图、验归属、跑检测、先清旧标签再插新的。 为什么查图和验归属要分开写:查不到是 404(这东西不存在),查到了但不是你的,是 403(存在, 不给)。合成一个 404 也行,那就什么都不说。 顺序上有个细节:跑检测之前就要把权限验完。反过来先跑检测再验权限,白跑一次不说, 还可能把别人的图读进内存。

二、三种进不来的情况

情况 实测结果
没登入 302,跳到 /?next=%2Fimages%2Fdetect%2F1
图不存在 404
图是别人的 403

第一条是 flask-login 干的:@login_required 拦下来,把原地址塞进 next 参数。 注意这里跳的是 main.index(因为 login_view 设的是它),不是登录页。 第三条最容易漏。少了它,任何登录用户随手改个 image_id 就能改别人的数据。 这种漏洞在网上有个名字,叫「水平越权」。

三、先清旧标签再插新的

found = detector.detect(path)
UserImageTags.query.filter_by(image_id=img.id).delete()
for it in found:
    db.session.add(UserImageTags(
        image_id=img.id, tag=it["label"], score=it["score"],
        box="%d,%d,%d,%d" % tuple(it["box"])))

实测算下来:第一次检测,标签 0 条变 2 条;同一张再检测一次,还是 2 条,没翻倍。 「先清」这一句少了,检测几次就累加几遍。一张图点五次,库里十条标签,页面上会显示十行, 用户以为模型抽风了。 清和插在同一个事务里:中途出错一起回滚,不会出现「旧的删了新的没进去」这种空窗。

也可以做「追加历史」——每次检测存一批、带时间戳。 那是另一种设计,要看业务要的是「这张图现在的结果」还是「这张图历次的检测记录」。教材要的是前者。

四、顺带存一张带框的图

img.box_filename = img.filename.replace(".", "_box.")
detector.draw(path, found, os.path.join(up, img.box_filename))

实测跑完上传目录里多一个 cat_box.png。列表页缩略图优先用它,没有就退回原图。 这一行不是教材的内容,是为了让「框」这件事在页面上看得见。 只存标签名的话,用户看到的是一行字,看不到框在哪。 代价是多一份磁盘占用。 真实项目里一般存缩略图,或者前端拿到坐标自己画——后者更省空间,但要把坐标以 JSON 发给前端。

五、一次检测到底花了多少时间

实测整条请求(含检测、画图、写库)最快 113.4 ms,最慢 141.2 ms。 这个数不能当成 YOLO 的性能:沙箱这个替身只看灰度阈值,比真模型快得多。 真接上 YOLO,CPU 上一张图通常要几百毫秒到一两秒。这个量级意味着什么?

Web 服务器的工作进程数一般是 CPU 核数乘二。 一张图占住一个进程一两秒,十个人同时点检测,后面排队的就得等十几秒。 所以检测这种活儿一般不同步做:要么丢进后台队列(Celery、RQ),前端先返回「处理中」; 要么至少给个明确的提示,别让用户对着转圈猜。 这一课仍用同步,因为它能跑通、能验收、代码短。但要知道这是「教材级别的简化」。

六、动手 Demo:检测端点:从一张图到几条标签

这个 Demo 把三种进不来的情况先试一遍,再正常检测一次,然后连点两次看标签会不会翻倍。 第六节的耗时是这一篇的重点:它决定这个功能能不能同步做。

@bp.route("/detect/<int:image_id>", methods=["POST"])
@login_required
def detect(image_id):
    img = db.session.get(UserImage, image_id)
    if img is None:
        abort(404)
    if img.user_id != current_user.id:
        abort(403)
    up = current_app.config["UPLOAD_FOLDER"]
    path = os.path.join(up, img.filename)
    if not os.path.exists(path):
        abort(404)
    found = detector.detect(path)
    UserImageTags.query.filter_by(image_id=img.id).delete()
    for it in found:
        db.session.add(UserImageTags(
            image_id=img.id, tag=it["label"], score=it["score"],
            box="%d,%d,%d,%d" % tuple(it["box"])))
    img.box_filename = img.filename.replace(".", "_box.")
    detector.draw(path, found, os.path.join(up, img.box_filename))
    db.session.commit()
    return redirect(url_for("images.index"))

实跑输出:

--------------------------------------------------------------------
检测端点:从一张图到几条标签
--------------------------------------------------------------------

  ① 没登入就点检测
  --------------------------------------------------------------------
  POST /images/detect/1(未登入)-> 302,Location=/?next=%2Fimages%2Fdetect%2F1

  ② 图不存在
  --------------------------------------------------------------------
  POST /images/detect/999 -> 404

  ③ 正常检测一张
  --------------------------------------------------------------------
  POST /images/detect/1 -> 302,Location=/images/
  标签 0 -> 2 条:[('spot', 0.409, '180,90,81,101'), ('spot', 0.288, '40,30,71,81')]
  上传目录 -> ['cat.png', 'cat2.png', 'cat_box.png']
  ! 端点里做了四件事:查图、验归属、跑检测、先清旧标签再插新的。
  「先清」这一句少了,重复检测就会一直累加。

  ④ 同一张再检测一次
  --------------------------------------------------------------------
  再检测一次 -> 标签 2 条(没翻倍)
  ! UserImageTags.query.filter_by(image_id=...).delete() 在同一个事务里跑,接着 insert 新的。
  所以每次检测的结果都是「这张图现在的样子」,不是历史累积。

  ⑤ 别人的图
  --------------------------------------------------------------------
  用户 1 去检测用户 2 的图 -> 403
  ! 403。这一步很容易漏——少了它,任何登录用户传个 image_id 就能改别人的数据。
  ! 顺便:abort(404) 与 abort(403) 的区别是「告不告诉对方东西存在」。
  对外接口一般用 404,站内用 403 更利于排查。

  ⑥ 一次检测的耗时
  --------------------------------------------------------------------
  整条请求(含检测)最快 118.6 ms,最慢 151.5 ms
  ! 这里量的是「请求进来到响应出去」,不是纯推理。
  沙箱这个替身只看阈值,比 YOLO 快得多——别把这几十毫秒当成 YOLO 的性能。
  ! 真接上 YOLO,CPU 上一张图通常要几百毫秒到一两秒。
  所以检测这种活儿一般不同步做:要么放后台任务,要么前端显示「处理中」。

  ⑦ 列表页把标记显示出来
  --------------------------------------------------------------------
  GET /images/ -> 200
  <tr><th>ID</th><th>文件名</th><th>缩略图</th><th>标记</th><th>操作</th></tr>
  <tr><td>1</td><td>cat.png</td>
  <td><img src="/images/file/cat_box.png" width="80"></td>
  <td>spot(0.409)
  <td><form method="post"
  <tr><td>2</td><td>cat2.png</td>
  <td><img src="/images/file/cat2.png" width="80"></td>
  <td></td>
  <td><form method="post"
  ! 缩略图优先用 box_filename,没有才退回原图——检测过的图看起来是带框的。

第一节那个 next 参数是 flask-login 自动加的,不用自己拼。 第三节的 0 -> 2 与第四节的还是 2,两句话拼起来才是「不会累加」的证明。 第五节那个 403 是水平越权被挡住的样子。

30 秒实验

  1. 把「先清旧标签」那一行注释掉,连点三次检测,看库里标签条数。
  2. 把 box="%d,%d,%d,%d" 改成存 JSON 字符串,看列表页取的时候要不要改。
  3. 想一想:如果检测要两秒,十个人同时点,页面会怎样?

常见误区

  1. 不验归属:任何登录用户改个 id 就能操作别人的图。
  2. 不先清旧标签:点几次就累加几遍。
  3. 先跑检测再验权限:白跑一次,还可能把别人的图读进内存。
  4. 把检测同步放在请求里还指望它能扛:慢操作会占满工作进程。
自测三问(点开对答案) 1. 端点四步的顺序是什么? —— 查图、验归属、跑检测、先清旧标签再插新的。 2. 为什么清和插要放在同一个事务? —— 中途出错一起回滚,不会留下「旧的删了新的没进去」的空窗。 3. 检测为什么要考虑放后台? —— 真模型一张图要几百毫秒到一两秒,会占住工作进程,多人同时点就排队。

标签进库了,下一篇让它在页面上露出来,再做删除。

相关笔记