当 AI 把窗外的鸟认成猴子:一台老开发板的观鸟相机调教记

用吃灰多年的 Google AIY Vision Kit,做一个会自己抓拍小鸟、还能告诉你它在想什么的观鸟相机

作者: shisaq 日期: June 3, 2026

我家飘窗外面常有鸟来落脚。于是我把一块吃灰多年的 Google AIY Vision Kit(树莓派 Zero + 视觉扩展板)架在窗台,想做一台”观鸟相机”:平时低成本待命,有鸟来了就自动录一段,顺便把画面推到 B 站当个佛系直播。

它确实跑起来了。但有天早上我亲眼看见一只鸟落在窗台,截了图,回头去翻录像——一段都没有

我第一反应是:”肯定是为了省直播带宽,画质压太狠,AI 认不出鸟了。”

后来我 SSH 上去翻日志,发现真相比这有意思得多:它不是认不出鸟,是把鸟认成了猴子。 这篇就把整个排查和调教过程记下来,既是给想自己做一台的人的教程,也是一次”怎么和一个能力有限的模型打交道”的实战。

最终成果

先看现在的效果:

  • 有活物落在窗台落脚区 → 自动录一段 10 秒 1080p 的清晰视频,文件名带上 AI 当时的判断(比如 bird_20260603_100052_parallel.mp4
  • 网页直播页底部实时显示”它现在看到了什么、置信度多少、录没录”
  • 一个 /log 历史页,能回看每一次识别
  • 按鸟的作息分时段调灵敏度:清晨、黄昏两个高峰更激进,夜里保守,免得录一堆风吹草动
  • 直播预览按需开启,没人看时不占摄像头、不耗带宽,全部精力留给”抓鸟”

观鸟相机抓拍到的灰喜鹊

上面这只灰喜鹊,就是新逻辑上线后自己抓下来的。隔着窗纱,但看得清清楚楚。

硬件与整体思路

硬件就是一套初代 Google AIY Vision Kit:

  • 树莓派 Zero(armv6l,性能很弱)
  • AIY Vision Bonnet 视觉扩展板,板上有一颗 Movidius Myriad 视觉处理芯片(VPU),可以在本地跑神经网络推理,不需要联网、不需要 GPU
  • 一颗树莓派摄像头,怼在飘窗玻璃上
  • 系统是 2017 年的老镜像(这点后面会反复咬我)

同系列的 Voice Kit 我之前也复活过,写过一篇《Google Voice Kit V1 基础硬件复活与配置指南》。这次是它的视觉版兄弟。

难点在于:树莓派 Zero 只有一颗摄像头、性能极弱,而我又想同时做三件事——本地 AI 检测、网页直播预览、录像。三件事都要摄像头,硬抢会打架。所以整套程序(一个常驻的 birdcam_app.py,用 systemd 托管)是这样设计的:

1
2
3
4
5
6
7
8
空闲时:只跑一路 640x360 的廉价 H.264 编码,单纯为了拿运动矢量
        └─ 只盯着"窝/落脚区"那一小块 ROI 有没有动静(省 CPU)
              │
   有动静 ──▶ 抓一帧全分辨率 JPEG,裁出落脚区,丢给 VPU 分类
              │
        判断要不要录 ──▶ 录 10 秒 1080p 高清 ──▶ 回到空闲
              │
  网页预览:只有有人打开网页时才编码一路低清 MJPEG,没人看就停

关键设计是检测和预览/直播是两条独立的路:检测永远用全分辨率抓拍,预览用的是另一路低清流。记住这一点,因为我一开始就栽在这。

第一个误区:画质不够?

我最初笃定是画质问题:直播为了省带宽,预览只有 384x216、JPEG 质量压到很低,糊成一团,AI 当然认不出。

翻代码才发现,检测这条路压根没用那个糊预览。它是这么抓帧的:

1
2
3
4
# 有运动时,抓一帧全分辨率的图,裁出落脚区再送进 VPU
camera.capture(buf, format='jpeg', use_video_port=True, splitter_port=0)
crop = Image.open(buf).crop(ROI)              # ROI = 落脚区在 1080p 下的坐标
classes = image_classification.get_classes(inf.run(crop), max_num_objects=5)

也就是说,喂给 AI 的是 1080p 原图裁出来的清晰局部,跟网页那路糊预览毫无关系。 而且我去查了下,B 站推流早就停了,预览也是按需开启——根本没有什么”直播在抢带宽导致画质牺牲”。

第一个假设,错得很干脆。那问题到底在哪?

翻日志:它其实看见鸟了

接着我去翻它的运行日志。两件事浮出水面。

第一,程序当时在”标定模式”。 代码顶上有个开关 CALIBRATE = True,这个模式下它只把识别结果记下来、存截图给我调参,任何情况都不录视频。我自己之前调着调着忘了关。所以”没录到”的最直接原因是:它根本没开录。

第二,那天早上它其实认出鸟了。 日志里清清楚楚一行:

1
07:12:37  motion -> BIRD junco/snowbird 0.15

junco(灯芯草雀)这个判断还算靠谱,但置信度只有 0.15,而我设的录像门槛是 0.30。就算不在标定模式,这只鸟也会因为”分数不够”被放走。

门槛太高?那把门槛调低不就行了。我天真地以为是这么个调参问题——直到我看清它对鸟到底都判成了什么。

真相:它把鸟认成了猴子

我把后来几次鸟来访的完整日志拉出来,对着看。空场景和有鸟时,模式截然不同:

场景 AI 的 top 判断
空栏杆 handrail(栏杆)、worm fence(蛇形栅栏)
玻璃/窗纱反光 mosquito net(蚊帐)、window screen(纱窗)
有鸟落脚 capuchin(卷尾猴)、langur(叶猴)、squirrel monkey(松鼠猴)、spider monkey(蜘蛛猴)、hog(猪)

没错。两次到访、十几帧画面,没有一帧报出”鸟”,全程在猴子和猪之间反复横跳。

为什么会这样?因为这块 2017 年的老套件上能用的,只有一个通用的 ImageNet 1000 类分类模型。它本来想匹配的鸟类专用模型(iNaturalist,认得几百种鸟),在这个老镜像上连导入都报错——这套模型是后来的固件才加的。我退而求其次用通用模型,再硬编码一张英文鸟名表去对照。

而一只灰身、黑头、长尾的灰喜鹊,隔着雾蒙蒙的玻璃、落在栏杆和绿植背景上,在 MobileNet 眼里最接近的东西,就是”蹲在树枝上的小型灵长类”。所以它一口咬定是猴子。

调门槛在这里完全没用:鸟类的分数压根是 0——因为它从没把鸟归到鸟那一类里去。

关键转弯:别问”是不是鸟”,问”还像不像那根空栏杆”

卡在这里的时候,我又把上面那张表多看了两眼,忽然意识到日志其实送了我一个能用的信号

  • 落脚区空着的时候 → 它永远报人造结构类:栏杆、栅栏、蚊帐、纱窗
  • 落脚区有活物的时候 → 它永远报动物类:各种猴、猪

它认不出”是什么鸟”,但”这是个死物,还是有活的东西“这个二分,它分得相当稳。

于是我把判断逻辑整个掉了个头。不再问”这是不是鸟”(它永远说不是),改问”画面还像不像那根空荡荡的栏杆“——不像了,就说明有东西落上来了,录就完事:

1
2
3
4
5
6
7
8
9
10
# 那些“落脚区没活物”时会出现的人造结构标签
STRUCTURE_WORDS = {'handrail', 'fence', 'mosquito', 'net', 'window', 'screen', ...}

def is_structure(label):
    return bool(tokens(label) & STRUCTURE_WORDS)

# 核心判断:top 标签不是空栏杆那类结构,且置信度够 → 判定“有活物”
occupied = top.score >= FLOOR and not is_structure(top.label)
if RECORD_ALL or occupied or bird_word_hit:
    record_clip()        # 录 10 秒 1080p,文件名带上 top 标签

改完后,我拿之前真实日志里的标签做了一遍回放验证:

1
2
3
4
5
6
0.52  capuchin   (鸟@6:18)  -> 录  ✅
0.18  squirrel monkey (鸟@6:37) -> 录  ✅
0.21  hog        (鸟@6:18)  -> 录  ✅
0.32  handrail   (空栏杆)   -> 跳过 ✅
0.24  mosquito net (空/玻璃) -> 跳过 ✅
0.15  solar dish (空/夜间)   -> 跳过 ✅

两次鸟到访全部命中,所有空场景全部跳过。判据从”认得出鸟”换成”认得出这不再是空栏杆”,一下就通了。

代价是偶尔空场景被误判成动物也会录一段——但漏拍是永久损失,误录我翻片时跳过就行。在”宁可多录别漏”这件事上,这个取舍我认。

让它把想法摆到台面上:网页识别日志

排查全程最大的体会是:我之所以被卡住,是因为一开始看不见 AI 在想什么。 所以我干脆把它的”内心独白”接到网页上。

实现很轻:程序里维护一个最近 60 条识别记录的环形缓冲,开两个网页端点:

1
2
3
events = collections.deque(maxlen=60)     # 每次识别都塞一条
# /events.json  ——  把缓冲吐成 JSON
# /log          ——  一个每 2 秒刷新的历史表格页

直播页底部再加一条实时浮层,三态显示:

  • 🐦 绿色 = 真报出了鸟种(少见)
  • 👀 黄色「活物?」= 不是空栏杆、判定有东西(这才是抓灰喜鹊的主力
  • — 白色「无鸟」= 空场景
  • 末尾 ●REC 表示这一帧触发了录像

直播页底部的实时识别浮层

上面这张截图里它显示 无鸟 (top: squirrel monkey 0.18)——一只猴子,但其实是只鸟。能直接在画面上看到它的判断,调参一下子从”盲猜”变成了”看着数据调”。

而那个 /log 历史页,活脱脱一个欢乐动物园。为了抓那几只鸟,它一路把画面认成了大熊猫、红毛猩猩、双杠、卷尾猴、野猪、线虫……凡是带 的那一行都触发了录像,而它们其实全是鸟:

识别历史页:一路把鸟认成大熊猫、猩猩、双杠,但带●的都录下来了

这页也正好印证了那条判据的妙处:我压根不在乎它把鸟叫成什么,只要它察觉到”这不再是那根空栏杆”,就录下来。

顺手修了个一直困扰我的小问题:时区。 我之前看浮层老是显示 22:37,可鸟明明是早上来的。原来这台 Pi 一直是 UTC 时区,比北京时间慢 8 小时,22:37 就是 06:37。一行命令的事:

1
sudo timedatectl set-timezone Asia/Shanghai

之后日志、录像文件名、网页时间戳,全都变成了我习惯的本地时间。这种”配置上完全没错、却一直让你读错信息”的小坑,最磨人。

按鸟的作息调:分时段灵敏度

最后一步是你能想到的那种”针对性优化”。鸟不是均匀出现的——清晨(dawn chorus)和黄昏是两个活动高峰,正午稀疏,夜里基本没有。我观察到的两次到访都在早上 6 点多。

所以我给它排了个班表,不同时段用不同的灵敏度(门槛越低越爱录、冷却越短越能把同一波多只鸟分开录):

1
2
3
4
5
6
7
8
9
10
11
12
13
# (录像门槛 floor, 冷却秒数, 名称)   —— 本地时间
PROFILES = {
    'dawn':  (0.08, 5,  '清晨高发'),   # 05:00-09:00  一天最活跃,宁可多录
    'dusk':  (0.08, 5,  '黄昏高发'),   # 16:00-19:00  第二个高峰
    'day':   (0.12, 8,  '日间常规'),   # 09:00-16:00  偶有到访
    'night': (0.22, 15, '夜间保守'),   # 19:00-05:00  天黑、基本是噪声
}

def time_profile(hour):
    if 5 <= hour < 9:   return 'dawn'
    if 16 <= hour < 19: return 'dusk'
    if 9 <= hour < 16:  return 'day'
    return 'night'

清晨那档把门槛压到 0.08,就是为了把”被认成猴子、分数又低”的鸟也稳稳拦下来。网页浮层和日志页顶部也会标出当前是哪一档。

抓到了

上线没多久,清晨高发档就立功了。那个文件名 bird_20260603_100052_parallel.mp4 里的 parallel 是个彩蛋——AI 那一帧把栏杆看成了”双杠(parallel bars)”。”双杠”不在我的空栏杆黑名单里,于是顺利走了”有活物”通道,把鸟录了下来。歪打正着,但正是新逻辑想要的效果:我不需要它认得准,我只需要它察觉到”这不是那根空栏杆了”。

文章开头那只灰喜鹊,就是这么来的。

想自己做一个?

如果你也有一块 AIY Vision Kit(或者任何能本地跑分类模型的树莓派 + 摄像头),复刻思路如下:

  1. 硬件:树莓派 + 摄像头,怼在鸟常来的窗/阳台。能本地推理最好(VPU/Coral),不能的话用云端 API 也行,只是要考虑频率和成本。
  2. 省着用摄像头:空闲时只跑一路低分辨率流拿运动矢量,只盯一小块 ROI(你的喂食器/落脚区),别全画面瞎检测。

    1
    2
    3
    4
    5
    6
    
    class MotionROI(picamera.array.PiMotionAnalysis):
        def analyze(self, a):
            sub = a[self.y0:self.y1, self.x0:self.x1]      # 只看落脚区
            mag = np.sqrt(sub['x'].astype('f4')**2 + sub['y'].astype('f4')**2)
            if (mag > MOTION_MAG).sum() > MOTION_BLOCKS:    # 足够多宏块在动
                self.motion = True
    
  3. 有动静再上模型:抓一帧全分辨率、裁 ROI、跑分类。别指望它认得出鸟种——先看看它对你的空场景都报什么标签,把这些”空场景标签”收集成黑名单。
  4. 判据用”还像不像空场景”,而不是”是不是目标物”。这是这次最关键的一招。
  5. 把模型的判断显示出来(哪怕只是打到日志/网页),你会省下大量盲调时间。
  6. 按目标的作息分时段调灵敏度。
  7. 录像用单独的高码率(我用 10 秒 1080p @6Mbps),临时文件写到 /dev/shm(内存盘),再用 ffmpeg 封装成 mp4,省 SD 卡寿命。

整套就一个 Python 文件 + systemd 服务托管,没有数据库、没有云。

收获

折腾完这一圈,有三条体会值得记下来。

一、不要跟一个做不到的模型死磕,去找它能给你的信号。 这块老板子上的通用模型永远认不出灰喜鹊,我再怎么调门槛都没用。但它能稳定区分”死物 vs 活物”——把问题从”是不是鸟”换成”还像不像空栏杆”,同一个模型立刻就够用了。能力的边界改不了,但问题可以换个问法

二、先让机器把它的判断摆出来,再动手调。 我被卡住的根因是看不见它在想什么。等我把识别结果接到网页上,看见它满屏报猴子,方向一下就清楚了。这和我上次折腾升降桌自动化的体会一模一样:把黑盒拆成能一眼验证的中间量,排错效率天差地别。

三、好用的自动化,往往是让设备贴合现实世界的节奏。 鸟有它的作息,相机就该跟着它的作息走,而不是 24 小时一个死板的阈值。

现在每天早上泡咖啡的时候,我会习惯性点开那个网页看一眼昨夜今晨的”战果”。它依然把鸟叫做猴子,但它再也没漏过一只。