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

标签是一张图对多条的从表,四个业务列: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 秒实验

  1. 给图片那一侧加上 cascade,再跑一遍第二节,看删图之后标签剩几条。
  2. 把 detect 里的阈值从 200 改成 250,看那两个框还在不在。
  3. 想一想:为什么 box 存成字符串 "180,90,81,101" 而不是四个整数列?

常见误区

  1. 标签表只存标签名:物件侦测不看见框等于白跑,用户不知道东西在哪。
  2. 以为删父行子行会自动走:不配 cascade 就是孤儿,SQLite 还默认不拦。
  3. 在端点里直接操作模型输出:换个模型就要重写端点。
  4. 用 cv2.imread 读非 ASCII 路径:静默返回 None,报错指向的地方跟原因无关。
自测三问(点开对答案) 1. 标签表的 image_id 为什么放标签这边? —— 一张图能有好几个标签,一个标签只属于一张图,所以从表是标签。 2. 删图片之后标签还在,是哪两件事凑成的? —— 模型上没配 cascade,SQLite 默认又不启外键约束,两层都不拦。 3. 检测程序库为什么定死 (label, score, box)? —— 换模型不用改端点,调用方只认这个形状。

模型和库都备好了,下一篇做端点:一次 POST 之后,标签怎么落进库里。

相关笔记