尧图精选

监控摄像头+BirdNet-Go:构建无人值守鸟类自动识别系统

🕒 发布时间:2026/9/4 3:02:47 📁 来源:尧图网络
如果你也在做类似的户外声音监测项目你会发现一个很现实的断层模型识别很容易持续无人值守地采集和判断很难。用监控摄像头搭鸟类自动识别系统核心难点并不在“认鸟”。BirdNET 这类模型已经能把“这段音频里是什么鸟”这件事做到相当好的程度真正折磨人的是另外几件事摄像头流怎么稳定采下来音频怎么切成模型能吃的片段7x24 连续跑的时候会不会重复告警断流之后能不能自己恢复积累几个月的数据怎么查、怎么用。这篇文章不打算只讲 BirdNET 的原理而是围绕一条完整可落地的链路来展开用带音频的监控摄像头做人站式采集把 RTSP 流里的声音连续切成短音频交给 BirdNet-Go 推理组件做物种识别再由一个常驻 Go 服务完成去重、通知和证据留存。读完你可以搭出一套类似的最小系统也能够理解它在生产环境里会遇到哪些坑。1. 为什么要自己搭一套自动鸟类识别系统先想一个具体场景。假设你在后院、农场、林地边缘或者某个研究点位放了一台监控摄像头。摄像头的价值不只是画面很多网络摄像头内置麦克风能连续录下周围的环境声。这正好满足了被动声学监测的采集需求设备固定、无人打扰、一天 24 小时都在工作。问题在于录下来的声音很难处理。按 3 秒一段来算一天就是 28800 段音频。如果靠人工去听基本不可能。如果只是把这些音频丢给某个模型批量跑一次那也只是把“人工听”换成了“机器听”离“系统”还差得很远。自动识别系统应当具备以下能力持续采集音频流而不是手动录制文件再上传。自动切片并触发识别不需要人每天登录看有没有新文件。对识别结果按物种、时间做去重避免同一种鸟每 3 秒骚扰你一次。保存原始音频、置信度、时间戳等证据方便事后核验。识别到目标物种时能主动推送通知并抓一张监控画面作为辅助证据。这套能力模型本身给不了你需要工程侧来解决。BirdNet-Go 解决的是“推理”这一环而把它变成可用的监测系统还需要你补上采集、调度、存储和通知。从成本角度看搭建这样一套系统也不需要特别贵的硬件。普通支持 RTSP 的网络摄像头、一个能跑 Linux 的小主机或旧电脑就够用。推理组件优先走 CPU 也能跑只是并发量不同而已。这也是这篇文章为什么值得实践的原因模型开源、工具链成熟、整体门槛其实没有想象中那么高。2. BirdNET、BirdNet-Go 与整个链路中的关键概念2.1 BirdNET 是什么BirdNET 是由康奈尔鸟类学实验室等机构参与研发的鸟类声音识别模型。它做的不是“看图识鸟”而是从音频里识别鸟鸣。你给它一段几秒的声音它能输出一个物种列表每个物种带一个置信度分数。传统鸟类调查靠研究者蹲点听音或者把录音拿回实验室看声谱图。而 BirdNET 用深度学习模型直接学习音频频谱上的时频模式相当于把“听音识鸟”这件事自动化了。它覆盖的物种数量已经达到数千种级别不同版本、不同区域模型覆盖范围有差异实际使用时以你拿到的模型文件说明为准。理解 BirdNET 要注意一点它不是一个通用语音识别模型而是专门针对鸟类声音设计的。所以不要拿它去识别猫叫狗叫那是另一类问题。它解决的是“有鸟叫声请告诉我是什么鸟”这一窄但高频的任务。2.2 BirdNet-Go 在系统中的定位很多第一次接触这个方向的同学会以为 BirdNet-Go 和 BirdNET 是两个互相竞争的模型。更准确的理解是BirdNET 是模型和算法的源头而 BirdNet-Go 这类工程组件负责把模型推理包装成更适合服务化、无人值守场景的程序。为什么人们愿意用 Go 来做这个包装因为这类监听系统本质上是一个长时间运行的守护型任务。它需要稳定的进程管理、明确的退出机制、方便的状态上报还要在并发处理多路音频时有足够的可控性。Go 在这类程序里很合适编译成单个二进制后部署简单标准库对 HTTP、JSON、定时任务、进程退出这些常见需求支持得比较完整。需要特别说明的是不同开源项目里叫“BirdNet-Go”的实现可能并不完全一样。有的会提供命令行接口有的会把推理服务封装成 HTTP 接口还有的会直接把 TensorFlow Lite 模型编译进 Go 服务里。因此这篇文章不会假定一个具体的 API 名称或命令参数而是把推理部分留成一个“适配点”。你只需要替换成自己实际安装的那个组件即可文章里整套采集、调度、通知、存储的架构思路不受影响。2.3 为什么用监控摄像头而不是单独放录音机单独放一台录音机当然也可以做鸟类声学监测但监控摄像头有它独特的优势自带供电和网络容易做成无人值守设备。用 RTSP 标准协议拉流音视频同步采集。识别到目标鸟时可以回抓画面给音频判断增加一个环境佐证。很多摄像头本身就有比较完整的支架、防水和户外适应能力。当然也有代价。普通监控摄像头的内置麦克风通常只有几百块钱的拾音水平不适合做精细的声学分析。它更适合“发现鸟、判断物种、长期趋势记录”这类任务。如果你需要高质量录音用于科研级分析建议另外接拾音器或专业录音设备。3. 系统整体架构与核心流程整个系统可以拆成四个环节代码和配置都围绕这四个环节展开。RTSP 音视频流 ↓ ① 音频采集与切片 ↓ ② 推理识别组件BirdNet-Go 适配层 ↓ ③ 分析调度服务去重、阈值判断、通知 ↓ ④ 结果留存与证据抓拍每一步要解决的关键问题如下。第一步是采集。摄像头上的音频通过 RTSP 流出需要用一个稳定的工具拉流、转码、切片。本文使用 ffmpeg 来做因为它成熟且几乎任何 Linux 发行版都有现成包。切片长度很关键。BirdNET 类模型通常按 3 秒左右的窗口判断声音所以这里直接切 3 秒一档对后面对齐最方便。第二步是推理。每产生一个新的 3 秒 WAV 文件就交给 BirdNet-Go 组件去识别。它的输出应至少包含物种名称和置信度。如果用的是 HTTP 服务版的推理组件这一步就变成一个 POST 请求如果用的是命令行版就通过进程调用。第三步是调度与策略。不是每个识别结果都值得通知这里需要设置置信度阈值比如只有大于 0.7 的才推送。同时要做物种维度的冷却去重避免同一种鸟在几分钟内重复推送几十次。第四步是留存。每次识别都应该把结果追加到结构化记录中可以是 JSON Lines也可以导入 SQLite 等数据库。推荐同时保留原始音频片段和录像抓帧方便事后人工复核。这套流程看起来简单但把每一环的边界理清楚以后你就知道代码该写在哪里、出了问题该查哪一层。4. 环境准备与前置条件下面的实践以 Linux 环境为主Debian/Ubuntu 或者树莓派系统都可以。Windows 用户可以走 WSL但设备管理和开机自启部分需要自己调整。推荐准备以下组件一台能连续运行的 Linux 主机树莓派 4 或普通 x86 小主机均可。一个支持 RTSP 且带音频的网络摄像头先确认后台有开启音频流。ffmpeg用于拉流、转码、切片和抓帧。Go 1.21 及以上版本用于编译分析调度服务。一个可用于接收消息的 Webhook例如企业微信群机器人或钉钉群机器人。sqlite3用于把识别结果导入本地数据库。安装基础依赖的命令如下sudo apt update sudo apt install -y ffmpeg sqlite3 golang-go确认 ffmpeg 版本ffmpeg -version版本建议不要太旧。如果你的系统自带版本过老可以考虑从官方静态构建包安装但这不属于必需步骤。创建好工作目录sudo mkdir -p /data/bird-system/{raw,done,errors,snapshots,logs} sudo chown -R $USER:$USER /data/bird-system需要再次提醒RTSP 地址里一般带摄像头用户名和密码不要把这类地址硬编码写进代码仓库。本地开发可以用环境变量生产环境用 systemd 的 EnvironmentFile并给摄像头配置一个权限受限的专用账号。5. 核心流程一用 ffmpeg 连续采集并切片音频音频采集是整个系统最基础、也最容易出问题的一环。先用一条命令手动跑通再考虑放到 systemd 里做开机自启。RTSP 地址规则因摄像头品牌而异。常见格式类似rtsp://用户名:密码摄像头IP:554/Streaming/Channels/101注意主码流和子码流。有些摄像头主码流只带高清视频不带音频音频在子码流上。如果发现拉流后没有声轨尝试换成子码流通道地址。下面这条命令会把 RTSP 流中的第一个音频轨取出来转成 48kHz 单声道 PCM WAV并按 3 秒一段切分保存mkdir -p /data/bird-system/raw ffmpeg -hide_banner -loglevel warning \ -rtsp_transport tcp \ -i rtsp://USER:PASSWORDCAMERA_IP:554/Streaming/Channels/101 \ -map 0:a:0 -vn \ -ac 1 -ar 48000 -c:a pcm_s16le \ -f segment -segment_time 3 -reset_timestamps 1 -strftime 1 \ /data/bird-system/raw/%Y%m%d_%H%M%S.wav解释几个关键参数-rtsp_transport tcp强制用 TCP 传输 RTSP避免 UDP 丢包导致音频中断。-map 0:a:0只取第一个音频轨丢弃视频轨减少转码负载。-ac 1 -ar 48000转成单声道、48kHz。BirdNET 一类模型默认对音频的采样率有要求不同推理组件可能不同。如果你的组件要求 32kHz 或 44.1kHz就同步修改这里。-f segment -segment_time 3以 3 秒为单位切片。-strftime 1文件名使用系统时间生成便于后续对齐时间。-reset_timestamps 1每个切片的时间戳从零开始避免切片之间时间戳错乱。执行后如果能看到 raw 目录下持续出现 3 秒的 WAV 文件说明采集已经跑通ls -l /data/bird-system/raw | tail如果没有 WAV 文件产生先看 ffmpeg 的输出日志。最常见的原因是摄像头音频不是标准 AAC 或 PCM或者 RTSP 地址里的通道不包含音频。这时可以去摄像头后台先把音频编码改为 AAC或者切换子码流地址测试。还有一个工程细节需要提前考虑ffmpeg 一旦断流会直接退出因此长时间无人值守必须把它交给进程守护工具管理。后面写到 systemd 时会给出配置。6. 核心流程二推理适配层与 BirdNet-Go 接入音频切好以后接下来要对它做识别。这一环在工程上要设计得足够独立因为不同版本的 BirdNet-Go 组件提供的接口不一样以后你升级模型或者切换组件不应该把整个服务都推翻重写。我建议的做法是在你的分析调度服务里保留一个单独的函数叫做推理入口。这个函数只做一件事输入一个 WAV 文件路径输出一个包含物种和置信度的结构体。至于底层是调用命令行、REST 接口还是本地函数库都在这个函数内部完成。下面用 Go 写一个最小但可以编译的分析调度服务骨架。它监听 raw 目录对每个新出现的 WAV 文件调用一次推理然后根据置信度判断是否通知并把结果追加到 JSON Lines 文件中。// 文件路径main.go package main import ( bytes context encoding/json fmt log net/http os os/exec path/filepath strings time ) const ( confidenceThreshold 0.7 alertCooldown 10 * time.Minute ) type Detection struct { Species string json:species Confidence float64 json:confidence } // runBirdNetGo 是整个系统的推理适配点。 // 如果你安装的 BirdNet-Go 是命令行工具就把下面的命令换成它的真实用法 // 如果它是 HTTP 服务就改成向对应地址 POST 音频文件。 func runBirdNetGo(ctx context.Context, wavPath string) (*Detection, error) { cmd : exec.CommandContext(ctx, birdnet-go, analyze, --input, wavPath, --format, json, ) var outBuf, errBuf bytes.Buffer cmd.Stdout outBuf cmd.Stderr errBuf if err : cmd.Run(); err ! nil { return nil, fmt.Errorf(run inference: %w, stderr%s, err, strings.TrimSpace(errBuf.String())) } var det Detection if err : json.Unmarshal(outBuf.Bytes(), det); err ! nil { return nil, fmt.Errorf(parse inference output: %w, raw%s, err, outBuf.String()) } return det, nil } func moveFile(src, destDir string) { dest : filepath.Join(destDir, filepath.Base(src)) if err : os.Rename(src, dest); err ! nil { log.Printf(move %s failed: %v, src, err) } } func appendJSONL(dataDir string, rec map[string]interface{}) { path : filepath.Join(dataDir, detections.jsonl) f, err : os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) if err ! nil { log.Printf(open jsonl failed: %v, err) return } defer f.Close() line, _ : json.Marshal(rec) f.Write(append(line, \n)) } func notify(webhookURL string, det *Detection, wavName string) { if webhookURL { return } msg : map[string]interface{}{ msgtype: text, text: map[string]interface{}{ content: fmt.Sprintf(检测到鸟类%s置信度 %.2f音频%s, det.Species, det.Confidence, wavName), }, } body, _ : json.Marshal(msg) resp, err : http.Post(webhookURL, application/json, bytes.NewReader(body)) if err ! nil { log.Printf(webhook post failed: %v, err) return } defer resp.Body.Close() if resp.StatusCode 300 { log.Printf(webhook response status: %d, resp.StatusCode) } } func main() { dataDir : os.Getenv(DATA_DIR) if dataDir { dataDir /data/bird-system } rawDir : filepath.Join(dataDir, raw) doneDir : filepath.Join(dataDir, done) errDir : filepath.Join(dataDir, errors) webhookURL : os.Getenv(WEBHOOK_URL) for _, dir : range []string{rawDir, doneDir, errDir} { os.MkdirAll(dir, 0755) } lastAlertAt : map[string]time.Time{} for { entries, err : os.ReadDir(rawDir) if err ! nil { log.Printf(read raw dir: %v, err) time.Sleep(5 * time.Second) continue } for _, entry : range entries { if entry.IsDir() || !strings.HasSuffix(entry.Name(), .wav) { continue } src : filepath.Join(rawDir, entry.Name()) info, err : entry.Info() if err ! nil { continue } // 文件刚生成时可能还在写入等 2 秒再消费。 if time.Since(info.ModTime()) 2*time.Second { continue } ctx, cancel : context.WithTimeout(context.Background(), 3*time.Minute) det, err : runBirdNetGo(ctx, src) cancel() if err ! nil { log.Printf(inference failed for %s: %v, src, err) moveFile(src, errDir) continue } rec : map[string]interface{}{ detected_at: info.ModTime().Format(time.RFC3339),
上一篇/下一篇内容由系统自动关联 返回资讯列表 →