端点四步:查图、验归属、跑检测、先清旧标签再插新的。 少一步「先清」,重复检测就会一直累加,一张图能堆出几百条标签。 检测是慢操作,同步做会让请求卡住——这是它跟前面那些端点最大的不同。
这一篇对应教材 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 秒实验
- 把「先清旧标签」那一行注释掉,连点三次检测,看库里标签条数。
- 把
box="%d,%d,%d,%d"改成存 JSON 字符串,看列表页取的时候要不要改。 - 想一想:如果检测要两秒,十个人同时点,页面会怎样?
常见误区
- 不验归属:任何登录用户改个 id 就能操作别人的图。
- 不先清旧标签:点几次就累加几遍。
- 先跑检测再验权限:白跑一次,还可能把别人的图读进内存。
- 把检测同步放在请求里还指望它能扛:慢操作会占满工作进程。
自测三问(点开对答案)
1. 端点四步的顺序是什么? —— 查图、验归属、跑检测、先清旧标签再插新的。 2. 为什么清和插要放在同一个事务? —— 中途出错一起回滚,不会留下「旧的删了新的没进去」的空窗。 3. 检测为什么要考虑放后台? —— 真模型一张图要几百毫秒到一两秒,会占住工作进程,多人同时点就排队。标签进库了,下一篇让它在页面上露出来,再做删除。
- ☐ (本篇完,下一篇 8.3 列表页显示标记与删除功能)