Python + OpenCV 识别 GIF 中旋转最快的图形:基于时间周期的动态识别

Python + OpenCV 识别 GIF 中旋转最快的图形:基于时间周期的动态识别

1. 识别问题与核心判断

画面中存在多个位置固定、旋转速度不同的线条图形,目标是找出旋转最快的候选并返回中心坐标 (x, y)。这不是单帧目标检测,因为单帧只能确定图形位置,无法得到旋转速度;也不能只比较相邻帧差,因为高速旋转时轮廓重合率已经很低,帧差会逐渐饱和,同时还会受到线条数量、图形面积和压缩噪声影响。

更稳定的判断依据是时间回归周期 :图形完成一次视觉旋转后,会重新出现与之前接近的画面。如果候选 A 每 15 帧回到原状态,候选 B 每 30 帧回到原状态,那么 A 的旋转速度约为 B 的两倍。因此,识别流程可以概括为:

text 复制代码
逐帧读取
  -> 定位全部候选
  -> 提取每个候选的时间序列
  -> 计算不同时间间隔下的自相似度
  -> 找到首个有效回归峰值
  -> 周期最短的候选为旋转最快目标

下面是同一动画的第 0、5、10、15 帧。多个图形都在变化,但不同候选回到初始形态的时间不同。

2. 时间最大值图提取完整旋转包络

设灰度帧序列为 I_t(x, y)。因为候选中心固定、轮廓随旋转变化,可以对全部帧逐像素取最大值,得到完整旋转轨迹:

text 复制代码
M(x, y) = max(I_1(x, y), I_2(x, y), ..., I_T(x, y))

对应代码如下:

python 复制代码
frames = []
while True:
    ok, frame = capture.read()
    if not ok:
        break
    frames.append(cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY))

temporal_max = np.max(np.stack(frames), axis=0).astype(np.uint8)

单帧中,图形旋转到侧面时可能变窄,部分线条也可能重合;时间最大值图累积了全部朝向,因此比任意单帧更适合确定候选范围。得到最大值图后,使用 Otsu 自动阈值、闭运算和轻微膨胀生成稳定包络:

python 复制代码
otsu_threshold, mask = cv2.threshold(
    temporal_max,
    0,
    255,
    cv2.THRESH_BINARY + cv2.THRESH_OTSU,
)

mask = cv2.morphologyEx(
    mask,
    cv2.MORPH_CLOSE,
    np.ones((3, 3), np.uint8),
)
mask = cv2.dilate(
    mask,
    cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5)),
)

下图依次为原始帧、时间最大值图和形态学处理后的二值包络。细线轮廓最终被合并为互相分离的候选区域。

3. 连通域定位候选和中心坐标

在二值包络上使用 connectedComponentsWithStats 提取候选框,并按面积、宽高过滤噪声:

python 复制代码
component_count, _, stats, _ = cv2.connectedComponentsWithStats(mask)

min_area = max(40, int(frame_width * frame_height * 0.0008))
max_area = int(frame_width * frame_height * 0.15)
candidate_boxes = []

for label in range(1, component_count):
    x, y, width, height, area = (int(v) for v in stats[label])

    if not (min_area <= area <= max_area):
        continue
    if width < 8 or height < 8:
        continue
    if width > frame_width * 0.45 or height > frame_height * 0.45:
        continue

    candidate_boxes.append((x, y, width, height))

候选中心取完整旋转包络的包围框中心:

python 复制代码
center_x = int(round(x + width / 2.0))
center_y = int(round(y + height / 2.0))

这里不使用某一帧的轮廓质心,因为非对称图形在旋转时,单帧质心会随投影形态发生偏移。时间包络中心覆盖所有朝向,作为点击坐标更稳定。

4. 构造候选时间序列并计算余弦自相似度

每个候选框增加 4 像素边距,然后从所有灰度帧裁剪同一空间区域,得到形状为 (帧数, 高度, 宽度) 的时间序列:

python 复制代码
padding = 4
x0 = max(0, x - padding)
y0 = max(0, y - padding)
x1 = min(frame_width, x + width + padding)
y1 = min(frame_height, y + height + padding)

crops = np.stack([
    frame[y0:y1, x0:x1]
    for frame in frames
]).astype(np.float32)

将每帧展平为向量并做 L2 归一化,归一化后的点积就是余弦相似度,可以降低亮度波动和压缩强度变化的影响:

python 复制代码
vectors = crops.reshape(crops.shape[0], -1)
vector_norms = np.linalg.norm(vectors, axis=1, keepdims=True)
vectors = vectors / np.maximum(vector_norms, 1e-9)

对于时间间隔 lag,统计所有可比较帧对的平均相似度:

python 复制代码
similarities = np.array([
    np.mean(np.sum(vectors[:-lag] * vectors[lag:], axis=1))
    for lag in range(1, usable_max_lag + 1)
])

其数学形式为:

text 复制代码
             1        T-lag
S(lag) = ---------- *  Σ   cosine(v_t, v_(t+lag))
          T - lag      t=1

如果候选每 P 帧完成一次视觉旋转,则 frame[t]frame[t+P] 接近,S(P) 会出现接近 1 的峰值。

5. 回归峰值就是视觉旋转周期

相邻帧本来就很相似,因此不能把 lag=1 的高相似度当成周期。当前算法从第 5 帧开始搜索,并要求峰值同时满足相似度阈值和局部极大条件:

python 复制代码
period_frames = None
recurrence_similarity = None

for lag in range(5, usable_max_lag):
    similarity = float(similarities[lag - 1])

    if (
        similarity >= 0.90
        and similarity > similarities[lag - 2]
        and similarity >= similarities[lag]
    ):
        period_frames = lag
        recurrence_similarity = similarity
        break

取第一个有效峰值,是因为它代表最短回归周期。假设视频帧率为 FPS、周期为 P 帧,则每秒视觉循环次数为:

text 复制代码
cycles_per_second = FPS / P

下图是实际样本中 4 个候选的相似度曲线。候选 #3 在第 15 帧首先出现接近 1.0 的回归峰值,候选 #0#2 要到第 30 帧才回归,所以 #3 的旋转速度最快。

需要注意,高度对称图形旋转半圈后也可能与原图相似,此时测得的是视觉周期而不一定是物理上的 360 度周期。只要同一动画中的候选使用相同图形,视觉周期仍然可以稳定用于速度排序。

6. 周期不可见时的运动量兜底与排序

动画长度有限,慢速候选可能在最大搜索范围内没有完成一次旋转。此时不能得到 period_frames,算法使用归一化相邻帧运动量作为兜底:

python 复制代码
normalized_crops = crops / 255.0
frame_differences = np.abs(np.diff(normalized_crops, axis=0))
mean_frame_energy = float(
    np.mean(np.sum(normalized_crops, axis=(1, 2)))
)

motion_score = float(
    np.median(
        np.sum(frame_differences, axis=(1, 2))
        / max(mean_frame_energy, 1e-9)
    )
)

使用前景能量归一化,可以减小候选面积差异;使用中位数,可以减小少数异常帧和压缩噪声的影响。运动量只用于没有周期证据时的比较,可靠的回归周期始终优先:

python 复制代码
fastest = max(
    candidate_results,
    key=lambda item: (
        item["period_frames"] is not None,
        1.0 / item["period_frames"]
        if item["period_frames"] is not None
        else 0.0,
        item["motion_score"],
    ),
)

排序键的含义依次是:是否检测到完整周期、周期倒数、归一化运动量。

7. 实际结果与正确点标注

文章样本为 340 x 24430 FPS60 帧,检测结果如下:

候选 中心坐标 周期 循环速度 归一化运动量
#0 (214, 58) 30 1.0 次/秒 0.937399
#1 (103, 120) 搜索范围内不可见 未计算 0.615209
#2 (216, 158) 30 1.0 次/秒 0.892573
#3 (60, 190) 15 2.0 次/秒 1.097034

候选 #3 的周期最短,最终返回 (60, 190)。识别完成后可以在每一帧绘制红色外圈和实心中心点:

python 复制代码
result = (fastest["center_x"], fastest["center_y"])

cv2.circle(annotated_frame, result, 14, (0, 0, 255), 2, cv2.LINE_AA)
cv2.circle(annotated_frame, result, 4, (0, 0, 255), -1, cv2.LINE_AA)
cv2.putText(
    annotated_frame,
    f"FASTEST ({result[0]}, {result[1]})",
    (text_x, text_y),
    cv2.FONT_HERSHEY_SIMPLEX,
    0.45,
    (0, 0, 255),
    1,
    cv2.LINE_AA,
)

8. 关键参数与技术总结

参数 当前值 技术作用
最小周期搜索位置 5 防止相邻帧被误判为完整周期
回归相似度阈值 0.90 判断候选是否回到相同视觉状态
最大时间间隔 45 限制周期搜索范围和计算量
候选裁剪边距 4 像素 防止完整旋转轮廓被裁掉
闭运算核 3 x 3 连接同一候选内部的相邻线条
膨胀核 5 x 5 合并候选的完整旋转包络
最小面积比例 0.0008 过滤小噪点
最大面积比例 0.15 过滤大块异常区域

参数调整顺序应当是:先确保时间包络能够正确分离候选,再观察相似度曲线是否出现清晰峰值,最后调整回归阈值和最大时间间隔。如果目标会发生平移,需要先做目标跟踪和中心对齐,再使用相同的周期检测方法。

整个识别方法的技术核心可以归纳为三步:使用时间最大值图确定"候选在哪里",使用余弦时间自相似度确定"候选多久回到原状态",使用最短有效周期确定"谁旋转最快"。相比直接比较相邻帧差,这种方法更符合旋转运动的周期本质,也更不容易受到图形面积和高速帧差饱和的影响。

相关推荐
长大198817 分钟前
Python+Flask 1小时搭建个人博客:比WordPress轻量10倍
后端
闪学it24 分钟前
Python测试开发进阶线上班28期
后端
Go_error24 分钟前
Badu/bus:Go 轻量级泛型发布/订阅事件总线
后端·go
Go_error27 分钟前
Go-redis:执行 Lua 脚本
后端·go
IT毕设实战小研1 小时前
电影数据可视化推荐系统
大数据·后端·爬虫·python·算法·信息可视化·课程设计
ssshooter1 小时前
AI 时代你不能不知道的 git worktree
前端·后端·面试
Cosolar1 小时前
一文弄懂 Agent Harness 与 Agent Runtime 的区别
java·后端·github
长大19882 小时前
Python代码慢?这5个性能优化技巧让速度提升100倍
后端
超超不吵吵2 小时前
Java AI转型实战(十):Agent 智能体初探
后端