1. 项目背景与痛点

1.1 为什么要在端侧做打斗检测

暴力行为检测首先是一个视觉识别问题,但一旦进入监控、安防、校园、园区等实际场景,它很快就不再只是一个“分类准确率”问题,而是一个系统问题。将识别任务部署在边缘设备,而不是全部送往云端,主要有四个原因。

维度云端集中处理端侧处理
隐私合规原始视频上云,数据暴露面更大原始画面可留在本地,风险更低
响应延迟依赖网络传输与服务端排队本地直接推理,链路更短
带宽成本连续视频上传成本高只上传事件结果或摘要即可
可用性网络异常会直接影响识别弱网甚至离线情况下仍可工作

对于“打斗检测”这类任务,这四点不是锦上添花,而是刚需。因为系统一旦真正面对摄像头流,结果必须满足两个条件:

  1. 发现得足够快,否则警报没有意义。
  2. 误报不能太频繁,否则使用者很快就会失去信任。

换句话说,这类项目的本质不是“离线跑一个不错的分类模型”,而是“在有限算力下,把检测、判断、可视化、报警全部压进一条稳定的实时链路”。

1.2 从原始监控画面到结构化动作特征

监控视频中的暴力行为通常伴随着三个典型难点:

  1. 背景复杂,照明不稳定,画质往往较差。
  2. 人物尺度变化大,且可能被遮挡。
  3. 判别依据不在静态外观,而在连续动作模式。

这也是为什么本项目最终没有把“整张图像”直接送进一个视频分类大模型,而是先做人体姿态估计,再做时序动作识别。

在这里插入图片描述

图 1:同一监控场景下,左侧是输入视频帧,右侧是 YOLO11n-pose 提取人体框与关键点后的结果。可以看到,算法关注点已经从背景纹理转移到人体运动结构。

2. 核心算法选型:YOLO11n-pose + 轻量时序分类器

2.1 为什么不直接使用端到端视频大模型

如果只从论文效果出发,3D-CNN、SlowFast、VideoMAE、Video Swin Transformer 等端到端视频模型当然更“正统”。问题在于,项目不是部署在高端 GPU 服务器上,而是要最终落到 Orange Pi AI Pro 这类边缘硬件上。

算法选型必须服从硬件约束,而不能反过来要求硬件迁就模型。以工程视角来看,端到端视频大模型存在几个明显问题:

方案优点局限
3D-CNN / Video Transformer时空联合建模能力强参数量大、显存和算力消耗高、端侧难以实时
直接图像分类实现简单只能看单帧,无法理解动作连续性
姿态估计 + 时序分类特征维度低、结构清晰、便于部署前处理设计要足够仔细,依赖姿态质量

这正是本项目采用级联架构的根本原因:

  1. 第一阶段负责把原始图像压缩成更抽象、更结构化的人体动作表示。
  2. 第二阶段只需要在较低维的序列特征上做时序判断。
  3. 这样做能显著降低分类器输入维度,减少板端实时推理的压力。

2.2 为什么选人体关键点,而不是直接吃 RGB 视频

在监控场景中,真正决定“是否发生暴力行为”的核心信息,并不是墙壁、桌椅、灯光或者背景纹理,而是人体姿态、肢体相对关系和短时间内的运动变化。人体关键点恰好能把这些信息提炼出来。

从表示学习角度看,姿态特征至少有三方面优势:

  1. 降噪:把背景和纹理细节大幅过滤掉,只保留与动作最相关的人体几何结构。
  2. 轻量:相比整帧图像,关键点序列的维度小得多,更适合端侧连续推理。
  3. 可解释:当模型输出“打斗”时,我们可以回头检查人体骨架变化,而不是面对不可解释的整图特征。

本项目中,单帧特征设计得非常明确:

  1. 每帧最多保留 3 个人。
  2. 每个人保留 17 个关键点。
  3. 每个关键点包含 xyconfidence 三个数值。

因此,单帧特征维度为:

d f r a m e = 3 × 17 × 3 = 153 d_{frame} = 3 \times 17 \times 3 = 153 dframe=3×17×3=153

而序列窗口长度固定为 41 帧,所以分类器输入张量可以写成:

X ∈ R 41 × 153 X \in \mathbb{R}^{41 \times 153} XR41×153

这个设计非常关键。它意味着后续的时序分类器不再需要理解完整图像,只需要理解“在 41 帧时间窗口内,这些关键点是如何变化的”。这正是轻量化的核心。

2.3 流水线是如何串起来的

从系统视角看,整条算法链路可以抽象为下面这个过程:

视频流 / 摄像头 / 本地视频

OpenCV 解码

YOLO11n-pose 姿态估计

保留最多 3 人的 17 点骨架

按 x y conf 展平为 153 维单帧特征

维护长度为 41 的时序窗口

轻量时序分类器 LSTM / GRU

平滑与阈值判定

报警 / 录像 / 可视化展示

这条流水线背后的思想其实非常朴素:

  1. 先用检测器把“看图像”变成“看人”。
  2. 再用时序模型把“看人”变成“看动作变化”。
  3. 最后再用状态机与平滑策略把“看动作变化”变成“稳定报警”。

2.4 仓库当前实现中的时序分类器事实

尽管本系列标题沿用了 LSTM 的表述,但仓库当前可运行的主线实现实际上是 GRU。下面这段代码就是项目中用于导出 ONNX 的 PyTorch 版本核心结构,已经很能说明问题:

class TorchGRUClassifier(nn.Module):
    def __init__(self, input_size=153, hidden_size=128, num_layers=2, dropout=0.2):
        super().__init__()
        self.gru = nn.GRU(
            input_size=input_size,
            hidden_size=hidden_size,
            num_layers=num_layers,
            batch_first=True,
            dropout=dropout,
        )
        self.head = nn.Sequential(
            nn.Linear(hidden_size, 64),
            nn.ReLU(),
            nn.Dropout(0.2),
            nn.Linear(64, 1),
            nn.Sigmoid(),
        )

为什么这里选择的是 GRU 而不是更复杂的时序模型?原因依然和部署目标有关:

  1. 循环网络对固定长度姿态序列的建模已经足够。
  2. 相比更重的时序架构,GRU/LSTM 更容易导出、更容易迁移到 ONNX Runtime。
  3. 对这种“二分类 + 中等长度窗口”的任务而言,工程收益往往大于继续堆复杂度的理论收益。

所以,严格地说,项目的真正设计思想不是“某一个具体的 RNN 单元一定最优”,而是“在端侧约束下,采用一个足够轻、足够稳、足够容易迁移的时序分类器”。

2.5 为什么还需要平滑与状态机

很多初学者会把模型输出的每一帧概率直接拿来显示,这在 demo 里几乎一定会导致一个问题:抖动

暴力行为本身是连续动作,但逐帧模型输出往往会受到姿态漏检、局部遮挡、单帧误判等因素影响。如果直接按瞬时概率做报警,界面会频繁在“打斗”和“非打斗”之间来回跳变。

因此,本项目在分类器之后又增加了一层非常关键的工程逻辑:

  1. 维护一个概率缓冲区,对最近若干帧做滑动平均。
  2. 只有平滑后的概率超过阈值,才认为当前进入打斗状态。
  3. 报警事件还需要加冷却时间,避免短时间内重复计数。

在这里插入图片描述

图 2:原始预测概率存在明显抖动,经过滑动平均后,红色曲线更加平稳,也更适合作为报警依据。

这一层逻辑非常“工程”,却恰恰决定了最终系统是否可用。很多时候,真正让项目从“能跑”变成“能演示、能部署、能说服人”的,并不是再多堆一层神经网络,而是把这种后处理细节做扎实。

2.6 选型不是拍脑袋:基线效果必须有证据

一个成熟的项目,算法选型不能只靠主观判断,还需要至少给出能够支撑决策的实验结果。本仓库中的测试集混淆矩阵就提供了一个非常直接的基线证据。

在这里插入图片描述

图 3:测试集混淆矩阵示意。图中可以读出 TN=185、FP=15、FN=10、TP=190。

由此可得:

A c c u r a c y = 185 + 190 185 + 15 + 10 + 190 = 93.75 % Accuracy = \frac{185 + 190}{185 + 15 + 10 + 190} = 93.75\% Accuracy=185+15+10+190185+190=93.75%

对于一个以人体关键点为输入、以轻量时序分类器为核心的端侧方案来说,这样的基线结果已经足以说明它不是“为了轻量而轻量”,而是在精度和部署性之间做了一次比较理性的平衡。

3. 工程架构设计:

3.1 本地原型和板端部署,本来就是两个世界

本项目从一开始就存在两类完全不同的需求。

阶段目标更优技术选择
本地开发期快速验证算法链路、训练模型、调试参数、做界面原型Python
板端部署期压榨性能、降低延迟、稳定接入视频流、对接 ONNX Runtime / NPUC++

这不是“语言之争”,而是任务分工。开发期最重要的是迭代速度,部署期最重要的是执行效率与可控性。

3.2 为什么后端选择 C++

在部署侧,视频处理主链路最终放在 C++ 中,核心原因有四个。

  1. 视频解码与内存控制更直接。连续视频流处理对内存分配、对象生命周期、缓冲策略都很敏感,C++ 更容易做细粒度控制。

  2. 更容易榨干 ONNX Runtime 与 NPU 后端性能。板端推理链路往往需要直接和动态库、Execution Provider、编译参数打交道,C++ 生态更加贴近底层。

  3. 更适合做多线程采集与推理解耦。当“采集速度”和“推理速度”不完全一致时,必须设计更稳的线程模型,避免延迟不断累积。

  4. 更适合承担事件录像、结果叠加、状态输出等系统级职责。这些事情单看都不难,但叠加起来以后,性能边界会非常明显。

  5. 通过 ONNX Runtime 接入 ONNX 分类模型与姿态模型。

  6. 提供了 cann Execution Provider 的配置入口,用于对接板端 NPU。

  7. 支持最新帧优先的实时流优化思路,尽量控制累积延迟。

  8. 在识别到事件时自动保存事件片段,兼顾演示效果与证据留存。

3.3 为什么前端仍然保留 Python

看到这里可能会有人问:既然后端都已经上 C++ 了,为什么前端不一起写成 C++?

答案很简单:没有必要

在这个项目里,前端承担的是管理、展示和交互职责,而不是主推理职责。用 Python 做这件事有非常明显的优势:

  1. 本地原型阶段可以直接用 Streamlit 快速搭出可交互页面。
  2. 板端部署阶段可以用 Flask 很快做出一个轻量级 Web 仪表盘。
  3. Python 的 Web 生态更成熟,开发、修改、调试成本都更低。

也就是说,本项目并不是“前后端分离”的互联网系统意义上的前后端,而是“高性能推理后端 + 高效率展示前端”的职责分离。

3.4 当前部署版的 IPC 实现:

这部分是整个项目里最有工程味道的设计之一。

部署版本并没有让 Python 仪表盘通过 HTTP 轮询去索取整帧图像,也没有把每一帧都通过 WebSocket 做大对象传输。相反,C++ 侧会把缩放后的预览图和状态信息直接写到 /dev/shm 中,然后由 Flask 仪表盘读取并展示。

核心思想可以概括为下面这段伪代码式实现:

cv::Mat smallFrame;
cv::resize(frame, smallFrame, cv::Size(640, 360));
cv::imwrite("/dev/shm/preview.jpg", smallFrame);

std::ofstream statusFile("/dev/shm/status.json");
statusFile << "{\"isFight\": true, \"prob\": 0.82, \"label\": \"fight\"}";

然后 Python 侧的 Flask 服务读取这些内容,把它们转成浏览器可消费的 MJPEG 视频流与状态接口:

@app.route('/video_feed')
def video_feed():
    return Response(generate_frames(),
                    mimetype='multipart/x-mixed-replace; boundary=frame')

@app.route('/status')
def status():
    return jsonify(json.load(open('/dev/shm/status.json', 'r')))

这里需要非常严谨地说明一件事:

  1. /dev/shm 本质上是 Linux 的内存文件系统,数据驻留在内存中,而不是机械硬盘或 SSD。
  2. 因此,这种方案确实比常规磁盘文件交换高效得多,也很适合竞赛演示。
  3. 但如果从最严格的系统定义来说,这还不是“裸 mmap 级别的真正零拷贝共享内存块通信”,因为其中仍然有 JPEG 编码/解码与文件读写动作。

换句话说,当前部署方案已经非常接近“共享内存思路”的轻量实现,足以支撑比赛和展示;而如果后续继续追求极限延迟,还可以再进一步升级到 mmapposix_ipc 形式的真正共享内存块方案。这也正是本系列第五篇要重点展开的内容。

3.5 为什么这种双栈架构是合理的

将整个系统拆成“C++ 负责实时主链路,Python 负责快速展示与管理”,本质上是在做一次工程上的资源最优配置:

  1. 把最吃性能、最需要确定性的部分交给 C++。
  2. 把最需要快速迭代、最需要界面表达的部分交给 Python。
  3. 用低开销的进程间数据交换把二者连接起来。

5. 本篇小结

到这里,第一篇要解决的问题基本已经完整回答:

  1. 为什么要做端侧暴力行为检测:因为它是一个隐私、延迟、带宽和稳定性共同约束下的系统问题。

  2. 为什么选 YOLO11n-pose + 轻量时序分类器:因为它在结构化表达、轻量部署和时序判别之间找到了很好的平衡点。

  3. 为什么工程上要采用 C++ + Python 双栈:因为本地研发与板端部署面对的是完全不同的最优目标,单一语言并不是最优解。

  4. Kaggle 数据集:https://www.kaggle.com/datasets/mohamedmustafa/real-life-violence-situations-dataset

  5. Ultralytics Pose 文档:https://docs.ultralytics.com/tasks/pose/

  6. ONNX Runtime 官方文档:https://onnxruntime.ai/docs/

n 双栈**:因为本地研发与板端部署面对的是完全不同的最优目标,单一语言并不是最优解。

  1. Kaggle 数据集:https://www.kaggle.com/datasets/mohamedmustafa/real-life-violence-situations-dataset
  2. Ultralytics Pose 文档:https://docs.ultralytics.com/tasks/pose/
  3. ONNX Runtime 官方文档:https://onnxruntime.ai/docs/
Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐