标签是一张图对多条的从表,四个业务列:image_id、tag、score、box。 不配 cascade,删图片会留下孤儿标签——SQLite 默认还不开外键,双重松。 检测程序库只认一个形状:(label, score, box) 的列表,换模型不用改端点。
这一篇对应教材 8.1 到 8.3:建立 UserImageTags 模型、物件侦测功能的表单类别、 设置物件侦测功能的程序库。 第 7 章把图片存进来了,这一篇给它贴标签——标签哪来?检测程序给。
一、标签表长什么样
class UserImageTags(db.Model):
id = db.Column(db.Integer, primary_key=True)
image_id = db.Column(db.Integer,
db.ForeignKey("user_image.id"),
nullable=False)
tag = db.Column(db.String(40), nullable=False)
score = db.Column(db.Float, nullable=False, default=0.0)
box = db.Column(db.String(60))
实测算出来的列是 ['id', 'image_id', 'tag', 'score', 'box']。
两个外键方向要先想清楚:image_id 指回图片,一张图 N 条标签。
从表在标签这边,因为一条图能有好几个标签,一个标签只属于一张图。
教材只存 tag(标签名)。这一课多存两个:score 是置信度,box 是框的位置。
理由是物件侦测不看见框等于白跑——只写一个 dog,用户没法知道狗在哪。
二、没配 relationship 会怎样
实测:插入两条标签之后,hasattr(img, "tags") 是 False。
想在模板里写 {% for t in img.tags %},先得在模型的图片那一侧加一行:
tags = db.relationship("UserImageTags", backref="image")。
没这一行,就只能自己写 select 去查。查一次两次还行,模板里就没法写了。
relationship 是 ORM 层的便利,不是数据库层的约束——不加它数据也不会错,只是取起来麻烦。
三、孤儿标签:两个默认值凑在一起
实测这一串,值得慢慢看:
| 动作 | 图片 | 标签 |
|---|---|---|
| 插入两条标签 | 2 张 | 2 条 |
| 删掉其中一张 | 1 张 | 2 条 |
删了父行,子行一条都没少。这是两件事凑在一起的结果。
第一,模型上没配 cascade,ORM 不知道「删图片要先删标签」。
第二,SQLite 默认 不开外键约束。PRAGMA foreign_keys 出厂是关的,所以数据库那一层也不会拦。
两个都不会吭声,孤儿就这么留下了。
配上 cascade="all, delete-orphan" 就正常了。
另一种写法是 DB 层的 ondelete="CASCADE" 加 passive_deletes=True,
但那要靠外键真的生效——SQLite 得手动打开。
孤儿标签不会报错,只会在统计里慢慢变多。这种错最难发现:页面上看不出异常,
等你发现「标签总数比图片多得多」可能已经过了几个月。
四、检测程序库只认一个形状
detect(path) -> [
{"label": "person", "score": 0.87, "box": (x, y, w, h)},
...
]
这是这一篇最该记住的一句话:上游给什么形状,下游就按什么形状处理。
有了这个约定,把 YOLOv5 换成别的模型,端点一行都不用改。
反过来,如果端点里到处是 results.xyxy[0] 这种东西,换模型就是重写。
box 用 (x, y, 宽, 高) 而不是两个角点,因为 PIL 画矩形要的正是这组数。
五、沙箱里的检测器:YOLO 换成了什么
教材这一层是 YOLOv5,torch.hub.load() 从 GitHub 拉权重再推理。
本沙箱没有 torch,这一课也没去装:CPU 轮子两百多兆,权重还得从 GitHub 拉,
而这一章要讲的不是模型本身,是模型怎么接进 Flask。
换成的是本机能真跑的检测器:灰度阈值找出亮块,再取每块的外接矩形。
实测跑出来两个结果,
框是 (180, 90, 81, 101) 与 (40, 30, 71, 81)——
这两个框是真算出来的,不是写死的。测试图上就画了两个亮矩形,位置正好对上。
差别要说清楚:标签不是 COCO 那 80 类,是固定的 spot;分数不是 softmax 置信度,是面积除以两万。
但接口形状一样,所以下游代码一个字都不用改。
这正是「接口约定」的价值:换掉实现,调用方不知道。
六、中文路径:imread 给你 None
def read_image(path):
buf = np.fromfile(path, dtype=np.uint8)
return cv2.imdecode(buf, cv2.IMREAD_COLOR)
实测 cv2.imread(中文路径) 返回 None,换成 imdecode 之后形状是 (240, 320, 3)。
imread 内部用 C 的 fopen,遇到非 ASCII 路径就打不开,
而且不抛异常、只在 stderr 打一条 WARN 然后返回 None。
危险的地方在于下一句:cvtColor(None) 才会炸,报错说「期望 ndarray」。
你去查那个报错,查到的全是「图片路径写错了」,而路径其实是对的。
imdecode 走内存:先用 numpy 把字节读进来,再解码。
这条路不经过文件名,中文、空格、表情符号都无所谓。
在 Windows 上做图像处理,这一条迟早会碰到。
七、模型加载一次,还是每次请求都加载
| 做法 | 单次开销 |
|---|---|
| 每次请求新建 | 35.6 ms |
| 模块级单例 | 0.002 ms |
这是实测,被测对象是 OpenCV 自带的 Haar 分类器,930 KB。 教材那个 YOLOv5 权重是十几 MB,加载一次是秒级。 所以「放模块级只加载一次」在那边不是优化,是必须——每请求加载一次,一个请求就要几十秒。 具体秒数这里测不出来:沙箱没有 torch,也不下模型。测不到的数不写。
写法就是那个经典的懒加载:全局变量为 None 时才构造,之后直接返回。 多线程下有点小风险(可能构造两次),但两次无害,不值得为它加锁。
八、框拿到手能干什么
实测 detector.draw() 另存一张带框的图,2034 字节,尺寸和原图一样是 320x240。
能做的三件事:画到图上、按坐标裁出来、把坐标存进库。三件都靠那一组数。
另存一份,不覆盖原图。原图是用户上传的原始证据,改了就没法回溯;
而且模型换了、阈值调了,都得能重跑一遍对比。
这一篇没碰表单类——上传表单第 7 章已经做完了,检测按钮是个纯 POST,不需要填任何字段, 所以用不上 FlaskForm。 教材 8.2 那个表单类在这一课里简化成了一个直接 POST 的按钮,道理是一样的:CSRF 保护不缺, 只是没有字段要校验。
九、动手 Demo:标签模型、检测程序库与框的落盘
这个 Demo 先把标签表建起来,看清「删图片留孤儿」这件事是怎么发生的,再把检测器跑一遍、 把框画到图上。 第四节那条 WARN 是本篇最实用的坑:中文路径下 imread 只返回 None,不报错。
def detect(path, min_area=400):
get_detector()
img = read_image(path)
if img is None:
return []
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
_, th = cv2.threshold(gray, 200, 255, cv2.THRESH_BINARY)
cnts, _ = cv2.findContours(th, cv2.RETR_EXTERNAL,
cv2.CHAIN_APPROX_SIMPLE)
out = []
for c in cnts:
x, y, w, h = cv2.boundingRect(c)
if w * h < min_area:
continue
out.append({"label": "spot",
"score": round(min(1.0, w * h / 20000.0), 3),
"box": (x, y, w, h)})
out.sort(key=lambda d: -d["score"])
return out
实跑输出:
--------------------------------------------------------------------
标签模型、检测程序库与框的落盘
--------------------------------------------------------------------
① 标签模型:一张图 N 个标签
--------------------------------------------------------------------
表 user_image_tags 的列 -> ['id', 'image_id', 'tag', 'score', 'box']
外键指向 -> image_id
! 四个业务列:image_id 指回图片、tag 是标签名、score 是置信度、box 是框的位置。
教材只存标签名,这里多两个——物件侦测不看见框等于白跑。
② 级联:删图片时标签跟不跟着走
--------------------------------------------------------------------
插入后 -> 标签 2 条,图片 2 张
显式查询取出来 -> [('spot', 0.41), ('spot', 0.29)]
img.tags 有没有 -> False
! False。没配 relationship,反向就取不到,只能自己写 select。
配上之后 img.tags 就是那一串标签对象,模板里能直接遍历。
删掉这张图 -> 图片 1 张,标签 2 条
! 标签一条都没少,成了孤儿。
原因是两件事凑在一起:模型上没配 cascade,
而 SQLite 默认不开外键约束(PRAGMA foreign_keys 是关的)。
! 配上 cascade="all, delete-orphan" 之后,删图片会先把标签删掉。
另一种写法是 DB 层的 ondelete="CASCADE" 加 passive_deletes=True,那要靠外键真的生效。
! 孤儿标签不会报错,只会在统计里慢慢变多——这种错最难发现。
③ 检测程序库的接口约定
--------------------------------------------------------------------
[约定]
| detect(path) -> [
| {"label": "person", "score": 0.87, "box": (x, y, w, h)},
| ...
| ]
上游给什么形状,下游就按什么形状处理——换模型不用改端点。
实际跑一次 -> 2 个结果
label=spot score=0.409 box=(180, 90, 81, 101)
label=spot score=0.288 box=(40, 30, 71, 81)
! 教材这一层是 YOLOv5,torch.hub.load() 拉权重再推理,返回的也是这个形状。
沙箱没装 torch,跑不了 YOLO,所以换成本机能真跑的检测器:灰度阈值找亮块。
这两个框是真算出来的,不是写死的。
④ 中文路径:cv2.imread 直接给你 None
--------------------------------------------------------------------
cv2.imread(中文路径) -> None
cv2.imdecode(np.fromfile(...)) -> 形状 (240, 320, 3)
! imread 内部用 C 的 fopen,遇到非 ASCII 路径就打不开,而且不抛异常、只返回 None。
紧接着 cvtColor(None) 才会炸,报错信息指向的地方跟真正的原因差很远。
! imdecode 走内存:先用 numpy 把字节读进来,再解码。
这条路不经过文件名,中文、空格、表情符号都无所谓。
⑤ 模型加载一次,还是每次请求都加载
--------------------------------------------------------------------
每次新建 -> 单次 35.8 ms
模块级单例 -> 单次 0.002 ms(第一次之后直接返回)
省下 35.8 ms/次请求
! 这个是 OpenCV 自带的 Haar 分类器,930 KB。
教材那个 YOLOv5 权重是十几 MB,加载一次要秒级——所以「放模块级只加载一次」在那边不是优化,
是必须。
! 具体的秒数这里测不出来:沙箱没有 torch,也不下模型。测不到的数不写。
⑥ 框拿到手能干什么
--------------------------------------------------------------------
画框另存 -> cat_box.png,2034 字节
尺寸:原图 320x240,带框图 320x240
! 框的坐标是相对左上角的 (x, y, 宽, 高),单位是像素。画到图上、裁出来、存进库,都是这一组数。
! 另存一份而不是覆盖原图:原图是用户上传的原始证据,改了就没了。
第二节那两个 2 与 2,是「删了父行子行还在」最直接的证据。 第四节两个结果并排看:一个 None、一个 (240, 320, 3),同一张图。 第五节 35.6 ms 与 0.002 ms 差了一万倍——这就是单例的用处。
30 秒实验
- 给图片那一侧加上 cascade,再跑一遍第二节,看删图之后标签剩几条。
- 把 detect 里的阈值从 200 改成 250,看那两个框还在不在。
- 想一想:为什么 box 存成字符串 "180,90,81,101" 而不是四个整数列?
常见误区
- 标签表只存标签名:物件侦测不看见框等于白跑,用户不知道东西在哪。
- 以为删父行子行会自动走:不配 cascade 就是孤儿,SQLite 还默认不拦。
- 在端点里直接操作模型输出:换个模型就要重写端点。
- 用 cv2.imread 读非 ASCII 路径:静默返回 None,报错指向的地方跟原因无关。
自测三问(点开对答案)
1. 标签表的 image_id 为什么放标签这边? —— 一张图能有好几个标签,一个标签只属于一张图,所以从表是标签。 2. 删图片之后标签还在,是哪两件事凑成的? —— 模型上没配 cascade,SQLite 默认又不启外键约束,两层都不拦。 3. 检测程序库为什么定死 (label, score, box)? —— 换模型不用改端点,调用方只认这个形状。模型和库都备好了,下一篇做端点:一次 POST 之后,标签怎么落进库里。
- ☐ (本篇完,下一篇 8.2 检测端点:从图片到标签)