
去年冬天我在一个仓配项目的监控室里蹲了三个晚上桌上摊着七张破损理赔单最便宜的一张赔了四百多最贵的一张连货带赔损近两万。运营的诉求很直接摄像头早就装满了装卸口和分拣台录像也存了三十天但每回出事都得靠人一帧一帧去翻翻到人也快废了。他们真正想要的不是更多摄像头而是让已经在转的这批视频流自己开口说话——哪些包裹是被扔的哪些箱子是被硬撕开的。这件事的落点就是标题里的那句话利用 OpenCV 检测物流过程中的暴力拆箱和暴力拿放行为。它听起来像个算法题实际做下来更像一道如何把人的主观判断翻译成物理量的翻译题。下面这套东西适合两类人看一类是手里已经有摄像头、想加一层行为识别的工程同学另一类是业务侧想知道这东西到底能做到几分准的负责人。我会把选型理由、代码骨架、阈值标定方法和踩过的坑都摊开讲能直接抄的部分我会写清楚不能抄的部分我会说明为什么不能抄。1. 从三张理赔单说起为什么暴力这件事得让机器先看一眼1.1 人工回看的成本账算不过来一个中等规模的分拨工位单班八小时处理量按两千件算。假设破损率千分之三一天就是六起纠纷。每起纠纷要定位到具体包裹、具体时间段靠人拖进度条熟练的质检员平均要花二十五到四十分钟。这还只是找得到的情况——很多包裹外包装看着完好里面碎了等客户投诉回来已经是三天后录像早被覆盖或者淹在几十 TB 的存储里。我在现场做过一次粗略统计一个质检员连续回看四小时监控后对快速运动的异常事件漏检率会明显上升特别是那种扔出去两秒就落地的动作眼睛容易跟丢。这不是人不认真是人眼对突然加速又突然归零这类瞬态信号的持续注意力本来就有限。机器不吃这套它不疲劳、不分心只要阈值给对了每帧都按同一把尺子量。所以这个项目的出发点不是用 AI 替代人而是做一层筛子把八小时里真正可疑的那几十个片段先捞出来人只看这几十段。筛子的召回率要高精确率可以妥协一点因为人还有最后一道判断。1.2 摄像头已经在位缺的是看懂的那一层很多团队一上来就想换成带 AI 功能的智能摄像机或者直接买整套行为分析方案。我的建议是先别急原因有三个。第一现有监控的安装位置往往已经是最优的——装卸口的斜俯视角、分拣台的正上方的俯视角这些位置是多年经验调出来的重新布点成本高且未必更好。第二行为识别的准确率高度依赖场景标定通用方案的默认阈值放到你的工位上大概率水土不服最后还是得自己调。第三也是最实际的一条产线动辄几十路视频全部换成智能设备是一笔硬支出而先用软件层在现有 RTSP 流上跑一遍成本几乎只有服务器和人力。OpenCV 在这个位置上的价值不是它是个深度学习框架恰恰相反——它是那层廉价、可解释、可调的预处理与物理量提取层。真正判断暴力这件事靠的是速度、加速度、形变这些物理量而算出这些量恰恰是 OpenCV 最擅长的活。1.3 三类行为三种完全不同的信号特征业务嘴里的暴力其实混了好几件事拆开看它们的可观测信号差别很大行为类型典型动作主要可观测信号时间尺度判定难点暴力抛掷手臂快速甩出、包裹脱手飞行手部速度峰值、物体脱离手部后做近似自由运动、落点冲击加速度0.3~1.2 秒与正常递送的边界模糊暴力摔砸举高后向下砸、脚踢竖直方向速度反向、落地瞬间速度归零的陡变0.2~0.5 秒相机视角倾斜时方向难以对齐暴力拆箱硬撕胶带、脚踩、猛拽箱体轮廓形变、边缘高频抖动、手与箱体的持续接触与相对位移2~10 秒正常打包动作也在动箱子看这张表能得出一个很重要的结论抛掷和摔砸是瞬时事件拆箱是持续事件。这两类必须用两套完全不同的判定逻辑硬塞进一个模型里只会两边都不讨好。后面第 3 节和第 4 节我会分别展开。2. 拆解暴力把它翻译成速度与形变两个可算的量2.1 为什么我不建议一上来就上端到端动作识别这话可能会得罪人但我在几个项目里反复验证过在样本量不足、场景高度固定、需要向业务解释为什么判它暴力的场合端到端时序动作识别模型那种输入一段视频直接输出动作类别的落地体验通常不如手工特征 阈值的组合。原因很实在。其一暴力抛掷在你的工位上一天可能就发生十几次一个月攒下来也就几百个正样本而且标注成本极高——你得让人反复看才能确定这一下算不算扔。其二业务方一定会问为什么这单被判成暴力如果你只能说模型输出的置信度是 0.87运营是不会认的但如果你能说这一帧手部速度达到 2.3 米每秒脱手后包裹在 0.4 秒内竖直位移 0.6 米且无减速符合抛掷特征这个沟通成本立刻降一个量级。其三算力。一条产线几十路流端到端模型要么上高价卡要么降到很低的分辨率导致精度崩掉。所以我的路线是用 OpenCV 把每一帧榨成一组物理量再用轻量的规则或很小的分类器做判决。这套东西的上限不如大模型但下限极高且每一层都能单独调试。2.2 像素不是米先把尺度标定做对这是最容易忽略、也最容易让整个系统失效的一步。光流算出来的是像素/帧你要判断手速是否超过 2 米每秒就必须知道一个像素对应多少毫米。而且这个比例在画面里不是常数——斜俯视的视角下画面远端的一米只有几十像素近端的一米可能有三百像素。同一个动作在画面下方判定为正常挪到上方就判成暴力这种bug我在第一个版本里就踩过。稳妥的做法是做一次平面单应变换把工位所在的地面或台面矫正成正视图import cv2 import numpy as np # 在画面上手工点选工位台面的四个角顺序左上、右上、右下、左下 # 单位像素 pts_src np.float32([[412, 588], [1187, 561], [1232, 902], [378, 931]]) # 目标尺寸按实际台面尺寸等比给比如台面 1.2m x 0.8m取 600x400 像素 # 这样 1 像素 2 毫米比例是确定的常数 pts_dst np.float32([[0, 0], [600, 0], [600, 400], [0, 400]]) M cv2.getPerspectiveTransform(pts_src, pts_dst) pix2mm 1200.0 / 600.0 # 2.0 mm/px def warp(frame): return cv2.warpPerspective(frame, M, (600, 400))变换之后整个工位平面上的尺度就统一了像素到毫米的换算变成一个常数。这一步做完后面所有的速度阈值才有意义。没有条件做正视图的场景比如装卸口是斜拍的、箱子在空间里高度变化很大退一步的做法是在工位平面上放一个已知尺寸的参照物——标准托盘、一段封箱胶带常见宽度 48mm 或 60mm、甚至一张 A4 纸用它反推该区域的 pix2mm并在 ROI 划分时按区域给不同的换算系数。这样做精度差一些但比全局用一个系数靠谱得多。2.3 时间基准别假设帧率是恒定的第二个高频坑是时间。很多人写代码时直接dt 1/25然后拿光流幅值除以它。但监控流经过转码、网络抖动、关键帧间隔调整之后实际帧间隔可能在 30ms 到 120ms 之间跳。帧间隔一抖速度就算错误报立刻上来。正确做法是尽量拿到真实时间戳cap cv2.VideoCapture(rtsp_url) prev_ts None while True: ok, frame cap.read() if not ok: break ts cap.get(cv2.CAP_PROP_POS_MSEC) / 1000.0 if prev_ts is None: prev_ts ts continue dt ts - prev_ts if dt 0 or dt 0.5: # 跳帧或时间戳异常丢弃这一对 prev_ts ts continue # 用 dt 参与速度计算 prev_ts ts如果取不到可靠时间戳有些封装格式返回 0那就退而求其次用本地接收时间time.time()打点同时对 dt 做限幅超过 200ms 的帧对直接跳过不参与判决。宁可少判几帧也不要拿错误的 dt 去污染阈值统计。3. 暴力拿放的检测链路从光流场一路走到抛掷判定3.1 帧差法和光流法我为什么两个都要帧差法便宜cv2.absdiff加个阈值再开运算几毫秒就出结果能告诉你这一块有东西在动。但它只给位置不给方向也给不了速度对慢慢挪动和快速甩出一视同仁。光流法贵一些但能给出每个像素的运动矢量。OpenCV 里最常用的是稠密光流 Farnebackdef flow_magnitude(prev_gray, next_gray, dt, pix2mm): flow cv2.calcOpticalFlowFarneback( prev_gray, next_gray, None, pyr_scale0.5, # 金字塔层间缩放 levels3, # 金字塔层数 winsize15, # 窗口越大越抗噪但会糊掉小目标 iterations3, poly_n5, poly_sigma1.2, flags0 ) fx, fy flow[..., 0], flow[..., 1] mag cv2.magnitude(fx, fy) # 像素/帧 speed_mm_s mag / dt * pix2mm # 毫米/秒 return speed_mm_s, fx, fy我的实践组合是帧差法跑全图做触发光流法只在触发区域内跑。全图跑 Farneback 在 1080p 上大概 60~90ms 一帧撑不住多路但把 ROI 裁到 320x240 左右只要 5~8ms。用帧差法先圈出运动区域光流只在这个区域算整体开销能压到十几毫秒。有一个参数值得单独说winsize。工位画面里包裹的边长通常在一百多像素手的宽度七八十像素winsize 取 15 比较合适。如果取到 31 以上手的运动会被周围静止的台面平均掉速度峰值直接掉一半取到 7 以下光流会在纹理弱的纸箱表面乱跳产生大量噪声峰值。这个值我一般固定 15然后靠后面的滑窗平滑来压噪。3.2 从光流场里把手和货分开光流图里一片红不一定是暴力可能只是有人从画面里走过。所以下一步是分离运动主体。这里我不建议上重模型用一个很朴素的办法连通域 运动方向聚类。mask (speed_mm_s 80).astype(np.uint8) * 255 # 80mm/s 以下视为静止噪声 mask cv2.morphologyEx(mask, cv2.MORPH_OPEN, np.ones((3, 3), np.uint8)) mask cv2.dilate(mask, np.ones((5, 5), np.uint8), iterations2) num, labels, stats, centroids cv2.connectedComponentsWithStats(mask, 8) regions [] for i in range(1, num): x, y, w, h, area stats[i] if area 400: # 太小忽略多半是噪声 continue regions.append({bbox: (x, y, w, h), area: area, centroid: centroids[i]})得到区域之后对每个区域的运动矢量做一个平均得到该区域的主运动方向和峰值速度用 95 分位数而不是最大值避免个别异常像素拉高。峰值速度用分位数是一件很有用的小事我吃过亏早期用mag.max()结果一个因为压缩块效应跳变的像素就能把整帧判成暴力。手和货怎么分在抛掷动作里两者的运动特征差异明显手在做圆弧或直线加速速度曲线是起-峰-停货在脱手后是匀速减速直到撞击。但从纯光流上区分它们需要额外线索我一般用面积和位置做粗分靠近画面边缘、面积中等偏大且连续多帧存在的区域更可能是人在工位中心区域、面积较小、轨迹独立的是货。如果现场条件允许加一个轻量的人手关键点检测几十 MB 的模型CPU 上也能跑十几帧会让归属判断稳很多尤其是多人同时作业的工位。3.3 抛掷判定卡尔曼跟踪加轨迹形状分析有了每帧的运动区域下一步是跨帧关联把同一个物体串成轨迹。OpenCV 自带的cv2.KalmanFilter足够用kf cv2.KalmanFilter(4, 2) kf.transitionMatrix np.array([[1,0,1,0], [0,1,0,1], [0,0,1,0], [0,0,0,1]], np.float32) kf.measurementMatrix np.array([[1,0,0,0], [0,1,0,0]], np.float32) kf.processNoiseCov np.eye(4, dtypenp.float32) * 1e-2 kf.measurementNoiseCov np.eye(2, dtypenp.float32) * 1e-1状态是[x, y, vx, vy]测量是质心坐标。跟踪器维护每个目标的轨迹历史长度保留最近 15~20 帧按 25fps 算约 0.6~0.8 秒刚好覆盖一次抛掷。抛掷判定的核心是对轨迹做二次拟合def is_throw(track, min_frames8, gravity_dir(0.0, 1.0)): if len(track) min_frames: return False, None ts np.array([p[t] for p in track], dtypenp.float64) ys np.array([p[y] for p in track], dtypenp.float64) xs np.array([p[x] for p in track], dtypenp.float64) ts - ts[0] # 沿重力方向投影避免视角倾斜导致拟合失真 gx, gy gravity_dir proj xs * gx ys * gy coef np.polyfit(ts, proj, 2) # proj a*t^2 b*t c a coef[0] # 像素坐标系下 y 向下增大若重力方向也向下则自由下落时 a 应为正 # 抛起阶段 a 为负下落阶段为正整体拟合看加速度量级 # 换算到物理量a_px_s2 - a_mm_s2 acc abs(a) * 2 * pix2mm * 1000 # polyfit 的二次系数是 0.5*a所以乘 2 return acc 3000, acc # 3000 mm/s^2 作为参考门槛这里有个关键细节重力方向必须标定。相机如果是斜俯视图像里的竖直向下和真实重力方向差了一个旋转角。做法很简单在画面里让一个物体自由落体一次或者拿一个垂线贴着画面拍一张取轨迹的前后两点算出方向向量。我见过直接用(0, 1)硬编码的实现在俯视角度大的工位上误报率能翻三倍。coef[0]的含义再强调一遍np.polyfit拟合y a*t^2 b*t c这里的 a 是0.5 * 实际加速度所以换算成加速度要乘 2。这个系数搞错的话阈值会整体偏差一倍很难排查。3.4 冲击时刻用加速度的差分找落地那一下抛掷的确认除了飞行段的抛物线还有一个强证据是落地瞬间的加速度突变。物体从高速运动突然静止速度曲线会出现一个几乎垂直的下降沿。这个信号用差分就能抓def impact_score(speed_seq, dt): # speed_seq: 最近若干帧的峰值速度mm/s if len(speed_seq) 4: return 0.0 v np.array(speed_seq, dtypenp.float64) acc np.diff(v) / dt # 一阶导单位 mm/s^2 # 只看速度快速下降的部分 decel -acc[acc 0] if decel.size 0: return 0.0 return float(decel.max())一次正常的轻放减速过程通常持续 200ms 以上峰值减速度在 5000 mm/s² 量级一次扔出去砸在地上减速时间可能只有 60~80ms峰值减速度能到 30000 mm/s² 以上。用这个量做二次确认比只看峰值速度准得多因为它对镜头内有人快速走过这类干扰不敏感——走过的人不会突然静止。这套链路跑通之后一次抛掷事件的完整证据链是手部区域峰值速度超阈值 → 目标脱离后轨迹符合抛物线拟合加速度量级接近重力 → 落地瞬间减速度超阈值。三条都满足才报警误报能压下来一大截。4. 暴力拆箱箱体形变与手箱交互的两条判据4.1 拆箱为什么比拿放难判拿放是点事件动作干净起止明确。拆箱是段事件持续时间可能十几秒而且过程中箱子一直在动——正常开箱也是要动箱子的。更麻烦的是拆箱的典型动作撕胶带、掰箱盖、拽内物从光流上看速度和加速度都不算高用抛掷那套阈值根本抓不到。所以拆箱必须换判据我实际用下来是两条并行箱体形变的剧烈程度和手与箱体的交互模式。4.2 箱体轮廓Hu矩、长宽比与最小外接矩形先说形变。一个完好的纸箱在画面里是一个接近矩形的四边形开箱过程中它会变形但正常开箱的变形是渐进的、有节奏的掀盖、拉扯胶带暴力拆箱的变形是突发的、幅度大的一脚踩下去箱子塌了猛地一撕箱盖翻折 90 度。判别方式是对箱体轮廓做逐帧特征提取def box_shape_features(contour): rect cv2.minAreaRect(contour) # ((cx,cy),(w,h),angle) (cx, cy), (w, h), angle rect if min(w, h) 1: return None long_side, short_side max(w, h), min(w, h) area cv2.contourArea(contour) rect_area w * h fill_ratio area / rect_area if rect_area 0 else 0 # 轮廓填充率 peri cv2.arcLength(contour, True) approx cv2.approxPolyDP(contour, 0.02 * peri, True) return { aspect: long_side / short_side, # 长宽比 fill_ratio: fill_ratio, # 越接近 1 越像完整矩形 vertices: len(approx), # 多边形顶点数 hu: cv2.HuMoments(cv2.moments(contour)).flatten() }三个指标的用法长宽比完好箱体在一个视角下比例基本恒定突变说明被挤压或折叠。经验上相对基线波动超过 15% 且持续时间超过 5 帧值得标记。填充率箱子被踩塌、被压扁时轮廓会向矩形内部凹陷填充率从 0.95 掉到 0.7 附近。Hu 矩cv2.matchShapes可以直接和基线形状比对它对旋转、缩放不敏感正适合处理箱子被搬动的情况。score cv2.matchShapes(cnt_baseline, cnt_current, cv2.CONTOURS_MATCH_I1, 0) # score 越小越相似超过 0.08 一般意味着形状发生了实质性改变Hu矩的一个坑它是七阶矩其中后几阶数值极小动态范围巨大直接比较容易被浮点误差放大。做法是取对数后归一化-np.sign(h) * np.log10(np.abs(h) 1e-30)再比。这个细节在手写的时候容易忽略结果就是同样的箱子在两次运行里 matchShapes 分数差别很大。4.3 前后帧结构差分比像素差更抗光照如果箱体在画面里的位置基本固定比如开箱台有工装定位那可以退一步用 ROI 差分但千万别用简单的absdiff因为开灯关灯、阴影移动都会让整块区域一起变。用结构相似性SSIM或者梯度差分更稳from skimage.metrics import structural_similarity as ssim def roi_change(prev_roi_gray, cur_roi_gray): score, diff_map ssim(prev_roi_gray, cur_roi_gray, fullTrue) # diff_map 里值越小的地方变化越大 changed (diff_map 0.6).astype(np.uint8) ratio changed.sum() / changed.size return score, ratioSSIM 相比直接做像素差对整体亮度变化更宽容——它比较的是局部结构而不是绝对灰度。开关灯的时候SSIM 会掉一点但下降幅度远小于 absdiff。ratio显著变化像素占比是判定的主指标超过某个比例且持续若干帧就说明箱体区域发生了结构性改动。4.4 手箱接触用关键点判断是不是在硬拽第二条判据更有针对性。暴力拆箱和正常开箱的差别很多时候不在箱子动多少而在手的动作。硬拽胶带的特征是手在很短的时间里位移很大而且位移方向与箱体固定方向相反。如果现场允许加一个轻量的手部关键点检测判定就变得很直接# 伪代码wrist/hand 是检测输出的关键点 def tearing_score(hand_tracks, box_roi, dt, pix2mm): if len(hand_tracks) 2: return 0.0 p0, p1 hand_tracks[-2], hand_tracks[-1] dist np.linalg.norm(np.array(p1) - np.array(p0)) # 像素 speed dist / dt * pix2mm / 1000.0 # 米/秒 # 手是否离开了箱体 ROI离开说明是在撕扯、拉扯出画面 inside cv2.pointPolygonTest(box_contour, tuple(p1), False) 0 return speed, inside实际用下来的经验值是手部速度超过 1.2 m/s 且箱体 ROI 内同时存在显著形变两个条件叠加就是很典型的硬撕。单纯手速高比如快速拿取物品但箱子没形变不报单有形变但手很慢比如正常掀盖也不报。这种双条件与的设计是把误报压下来的关键。如果算力紧张、跑不动关键点检测有个更省的办法把箱体 ROI 和运动区域做交并比。暴力拆箱时运动区域会长时间贴着箱体边缘运动正常开箱的运动区域更多在箱体上方。用intersection / union的时序方差来间接表达这个差异虽然粗糙但在我试过的几个工位上够用。5. 阈值标定别拍脑袋定数字5.1 正负样本从哪来怎么标这是整个项目里最费人力、也最不能省的一步。我的做法是从历史录像里挑出已经产生理赔或有投诉记录的时段作为弱正样本这些时段里肯定有暴力行为但不一定每一秒都是再随机抽取同工位、同时段的正常作业片段作为负样本。正负比做到 1:20 就够用了。标注不需要精确到帧。只需要标出事件时间段然后在时间段内跑检测看能否命中。这大幅降低标注成本——一个人一天能标两百段。5.2 分位数法用正常数据自己定边界定阈值最忌讳的是看几条视频感觉一下。我的习惯是拿纯正常样本跑一遍统计特征的分布然后取高分位数import numpy as np def pick_threshold(normal_scores, quantile0.995, safety1.2): q np.quantile(normal_scores, quantile) return q * safety用 99.5 分位再乘 1.2 的安全系数含义是正常作业里最极端的 0.5% 的 1.2 倍。这样做的逻辑是宁可让阈值略高一点让一部分轻微暴力漏掉也不要阈值太低导致每小时报几十次警——报警太频繁运营三天就会把这个功能关掉。采集正常样本时有个细节要注意要覆盖不同班次和不同光照。白班和夜班的光照差异会导致光流幅值整体偏移如果只用白班样本标定夜班就会狂报。我一般要求至少覆盖早班、中班、夜班各 4 小时样本量不少于 20000 帧的统计。5.3 ROC 曲线找平衡点有了正负样本就可以画 ROC 找合适的操作点from sklearn.metrics import roc_curve, roc_auc_score fpr, tpr, thresholds roc_curve(labels, scores) auc roc_auc_score(labels, scores) # 业务偏好召回优先误报容忍到每小时 2 次以内 target_fpr 0.02 idx np.searchsorted(fpr, target_fpr) best_thr thresholds[idx] print(fAUC{auc:.3f}, thr{best_thr:.2f}, TPR{tpr[idx]:.3f})这里我要提醒一个非常容易被忽略的点AUC 高不代表好用。我做过一个工位AUC 0.94 看着不错但实际部署后一小时报十几次。原因是正样本极少、负样本极多ROC 曲线在低 FPR 区间的表现被整体平均掩盖了。这种不平衡场景下应该看 PR 曲线或者干脆直接统计每千帧误报数这个更贴近业务的指标。5.4 滑窗投票单帧判决一定要平滑单帧超过阈值就报警是最常见的误报来源。加一个滑动窗口投票from collections import deque class VoteWindow: def __init__(self, size12, need4): self.buf deque(maxlensize) self.need need def push(self, flag): self.buf.append(1 if flag else 0) return sum(self.buf) self.need def reset(self): self.buf.clear()size12, need4是我常用的一组按 25fps 算相当于半秒内至少 4 帧命中。这个组合能滤掉绝大多数单帧噪声同时对真实的快动作仍然敏感一次抛掷通常有 8~15 帧符合条件。窗口长度和帧率强相关如果你的抽帧率是 10fps那 size 和 need 都要相应缩小否则一个 0.3 秒的动作可能只落进 3 帧永远凑不够 4 票。6. 误报清剿四类干扰源逐个处理6.1 相机抖动全局运动补偿必须先做装在货架或者临时支架上的摄像头叉车经过时整个画面都会晃。画面一晃光流全图都在动速度峰值直接爆表。这个坑不处理系统基本没法用。解决办法是用特征点匹配估计全局单应变换然后把光流场减去这个全局运动def compensate_global_motion(prev_gray, next_gray): orb cv2.ORB_create(500) kp1, des1 orb.detectAndCompute(prev_gray, None) kp2, des2 orb.detectAndCompute(next_gray, None) if des1 is None or des2 is None or len(kp1) 10 or len(kp2) 10: return None bf cv2.BFMatcher(cv2.NORM_HAMMING, crossCheckTrue) matches bf.match(des1, des2) if len(matches) 12: return None matches sorted(matches, keylambda m: m.distance)[:80] src np.float32([kp1[m.queryIdx].pt for m in matches]).reshape(-1, 1, 2) dst np.float32([kp2[m.trainIdx].pt for m in matches]).reshape(-1, 1, 2) H, mask cv2.findHomography(src, dst, cv2.RANSAC, 3.0) return H拿到 H 之后有两种用法。一是先用cv2.warpPerspective把上一帧对齐到当前帧再做差这样差出来的只有真实运动。二是从 H 里拆出全局平移量直接减去。前者更严谨后者更快我一般用前者因为抖动往往不只是平移还带旋转。判断是抖动还是真实运动的简易判据如果特征点匹配的内点数占比很高比如 60%且位移方向全场一致基本就是抖动如果是局部区域在动、其他区域静止那就是真实运动。6.2 光照突变与阴影仓库里开门、开灯、叉车灯扫过都会造成大面积灰度变化。前面提过用梯度或者 SSIM 代替直接差分这是基础。再补两条实操经验第一预处理里别用直方图均衡。很多人为了增强对比度加一步cv2.equalizeHist结果光照一变化均衡后的图像差异被放大光流噪声反而更多。用cv2.createCLAHE(clipLimit2.0, tileGridSize(8,8))温和一些或者干脆不做。第二阴影会影响轮廓检测。箱体旁边多一块影子findContours可能就把影子和箱体连成一片导致面积、长宽比全错。处理方式是在 HSV 空间做阈值把亮度低但饱和度也低的区域典型阴影先排除或者在提取轮廓前用形态学闭运算把断裂补上再取最大连通域。6.3 多人交叉与目标归属两个工人同时在工位上作业时光流区域会粘连轨迹关联会错乱导致张三的动作算到李四头上。这个问题在没有手部检测的情况下最难解。折中的处理是按 ROI 分区判定。把工位按物理位置划成几个互不重叠的判定区开箱台、称重台、暂存位每个区域独立维护自己的速度序列和投票窗口。这样即使两个区域的运动在画面上相邻判定也不会互相污染。代价是区域边界附近的行为可能判不准但比起全局混算这个代价可以接受。如果现场确实是多人近距离协作、无法分区那我的建议是老实加上目标检测和跟踪哪怕是一个很轻的检测器按人分配 ID再把运动区域归属到最近的人。这一步的收益远大于在光流层面做各种花哨的优化。6.4 传送带背景运动分拣线上的传送带一直在动用背景建模cv2.createBackgroundSubtractorMOG2会把整条带子都判成前景。这是个死结别在这上面浪费时间。正确思路是在传送带区域不做运动检测只检测货从带上离开、进入人工操作区之后的动作。或者如果你确实需要在带子上判就先用带子的运动方向做一次全局补偿传送带速度是已知的可以标定成固定像素速度补偿完再检测。后者实现成本高我一般选前者。7. 边缘部署的性能账把帧率挤出来7.1 分辨率与算力的取舍先算像素预算Farneback 的耗时大致和像素数成正比。1080p约 200 万像素单帧 60~90ms480p约 40 万像素大概 8~12ms。判暴力行为其实不需要高清——手宽七八十像素、箱子一百多像素就足够算光流了。所以第一个优化动作永远是降采样def prepare(frame, target_w640): h, w frame.shape[:2] scale target_w / w small cv2.resize(frame, (target_w, int(h * scale)), interpolationcv2.INTER_AREA) gray cv2.cvtColor(small, cv2.COLOR_BGR2GRAY) return gray, scale注意INTER_AREA而不是默认的INTER_LINEAR。降采样用线性插值会产生锯齿光流在这种锯齿上会抖INTER_AREA是区域平均更平滑。640 宽是个比较稳妥的起点。如果工位在画面里占的比例很小比如一个广角相机看整个仓库那 640 宽下箱子只有二三十像素光流算不准这时应该做的是先裁 ROI 再降采样而不是提高整体分辨率。7.2 跳帧策略与算法降级25fps 全跑没必要。抛掷动作持续 0.3~1.2 秒10~12fps 的采样率足够捕获再高只是浪费算力。我通常设置成每三帧取一帧等效 8fps配合前面说的滑窗投票调整。算法降级的顺序应该是先用最便宜的帧差法判有没有动没动直接跳过仓库里大部分时间画面是静止的这一条能省下 60% 以上的算力动了再算光流光流超阈值了才做轨迹拟合和形变分析。这种级联的设计比任何单点优化都有效。def pipeline(frame, state): gray, _ prepare(frame) if state.prev_gray is None: state.prev_gray gray return None diff cv2.absdiff(gray, state.prev_gray) if (diff 25).sum() 300: # 明显没动静直接返回 state.prev_gray gray return None # 第一级光流 speed, fx, fy flow_magnitude(state.prev_gray, gray, state.dt, pix2mm) if speed.max() 100: # 整体很慢不可能是暴力 state.prev_gray gray return None # 第二级区域提取 轨迹 # 第三级抛物线拟合 / 形变分析 ...7.3 多路视频的进程模型一路视频一个进程比一个进程里开多线程更稳。原因是 OpenCV 的很多函数内部已经用了多线程可以cv2.setNumThreads(1)关掉再叠 Python 的 GIL多线程基本拿不到加速。import multiprocessing as mp def worker(cam_cfg, out_queue): cv2.setNumThreads(1) cap cv2.VideoCapture(cam_cfg[url]) # ... 主循环结果写 out_queue if __name__ __main__: q mp.Queue(maxsize500) procs [] for cfg in camera_list: p mp.Process(targetworker, args(cfg, q)) p.start() procs.append(p)按经验一台 8 核 16G 的普通服务器跑 640 宽、8fps、带级联降级的 12~16 路是可行的。如果要跑更高帧率或者加手部检测路数要减半。结果队列一定要设上限maxsize否则下游写库慢了会直接把内存吃满。队列满了就丢最旧的告警——暴力告警的实时性比完整性重要。7.4 上线之后才开始的调优这一点我想专门说因为很多团队把跑通当成完事。实际项目里上线后的第一个月才是调优的主战场。我的做法是每天导出前一天的报警记录按工位分组统计报警次数然后人工抽看其中 20 条。如果某个工位一天报警超过 30 次基本可以断定是标定出了问题光照变化、相机被撞歪了、工位布局改了。这时候要做的是重新标定而不是去调阈值——阈值调下去真正的暴力也会一起漏掉。还有一种情况是报警量突然归零。这通常不是变好了而是视频流断了、或者光照变化导致前置的运动检测一直判没动。所以系统里必须有心跳监控连续 N 分钟没有任何运动事件要单独告警提醒人去检查摄像头。我在实际项目里的体会是这套东西的技术难度不在算法本身而在标定和运维的闭环做得够不够细。同一套代码标定认真的工位能做到误报每小时个位数、召回八九成标定敷衍的工位报警列表根本没人看。所以后来我干脆把标定流程做成了半自动的现场放一个参照物走一遍程序自动提取 pix2mm、重力方向、各 ROI 基线特征生成一份配置文件人工只做确认。这一步省下来的时间远比在算法上多抠两个点精度值钱。