构思
将摄像头拍到的画面大概分为三层:背景层,动态层,障碍层,如示意图:
背景层为绿色区域,动态层为蓝色区域,障碍层为红色区域。
其中障碍层是我们需要得到的结果,动态层和背景层需要被剔除。
我们读取一段完整视频,通过对比上一帧和下一帧找到画面中运动的区域,再对该区域进行泛洪填充算法扩散到背景层,像PS里的快速选择工具一样,每一个会动的物体的路径是快速选择画笔挥舞的方向,即可实现障碍层的分离。
视频素材
b站找了些需要用到的素材
来源:监控录像里拍摄到的悲剧傻缺视频集锦_哔哩哔哩_bilibili
从中截取了代表性片段:
代码编译
运动比对
可以使用OpenCV中的帧差法(Frame Difference)来检测两个连续帧之间的差异,再将每一帧的结果叠加,记录运动路径,得到运动层区域。
while True:
# 逐帧读取视频
ret, curr_frame = video.read()
# 如果视频读取结束,则跳出循环
if not ret:
break
# 计算当前帧与初始帧的差异
diff = cv2.absdiff(prev_frame, curr_frame)
# 将差异图像转换为灰度图像
gray = cv2.cvtColor(diff, cv2.COLOR_BGR2GRAY)
# 对灰度图像进行阈值处理
_, thresh = cv2.threshold(gray, 30, 255, cv2.THRESH_BINARY)
# 对二值图像进行腐蚀和膨胀操作,以去除噪声
kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (3, 3))
thresh = cv2.morphologyEx(thresh, cv2.MORPH_OPEN, kernel)
# 在原始帧上绘制静止区域的边界框
contours, _ = cv2.findContours(thresh, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
for contour in contours:
(x, y, w, h) = cv2.boundingRect(contour)
cv2.rectangle(curr_frame, (x, y), (x + w, y + h), (0, 255, 0), 2)
# 显示当前帧
cv2.imshow('Static Region', curr_frame)
# 按下 'q' 键退出播放
if cv2.waitKey(1) & 0xFF == ord('q'):
break
# 将当前帧设为初始帧
prev_frame = curr_frame
将运动追踪结果使用绿色方框表示,得到的结果很细散,我们可以进一步对图像腐蚀和膨胀让运动像素连通,也可以忽略掉运动太小的区域,优化后对比如图:
优化后画面右侧玻璃反光也得到了改善,且运行速度变快。
泛洪填充
泛洪填充的效果十分类似于PS中的快速选择工具或油漆桶,可以查找颜色相近的区域。
自动调节扩散容差
floodFill()函数的参数关键在于指定扩散容差,由loDiff和upDiff决定。官方是这样解释的:
loDiff
(lower difference)参数是一个整数类型的值,指定了种子像素与相邻像素之间的最小颜色差异。当像素之间的颜色差异小于loDiff
时,这些像素被填充到同一个区域内。
upDiff
(upper difference)参数也是一个整数类型的值,指定了种子像素与相邻像素之间的最大颜色差异。当像素之间的颜色差异大于upDiff
时,这些像素不会被填充到同一个区域内。
我读了之后云里雾里,不明白种子像素与相邻像素之间的最小与最大颜色的体现,具体应该怎么调节,所以这里只给loDiff和upDiff相同参数。
floodFill()函数返回值的第一个是填充像素数目,我们可以设置一个最小最大值来判断填充效果,以此调节扩散容差。为了防止程序跑死或始终不在调节期望区间,设置最大调节次数,加入阈值反复横跳判断。
在填充后的图像上画一个绿色圆圈代表已进行泛洪填充。
def Fill_Pexl(x, y, color, low, high):
global mask, first_frame
thresh = 5 # 初始阈值
img_copy = []
mask_copy = []
last_step = 0 # 上一步操作
times = 0 # 反复横跳次数
runtimes = 0 # 运行次数
while True:
runtimes = runtimes + 1
img_copy = first_frame.copy()
mask_copy = mask.copy()
area = cv2.floodFill(img_copy, mask_copy, (x, y), color, (thresh,thresh,thresh), (thresh,thresh,thresh))
print(thresh, area[0])
if low < area[0] < high:
break
elif runtimes > 10 and (last_step == 2 or runtimes > 100): # 防止跑死
break
elif times > 3 and last_step == 2: # 防止阈值反复横跳
break
elif low >= area[0]:
if last_step == 1:
times = 0
else:
times = times + 1
last_step = 1
thresh += 1
elif high <= area[0]:
if last_step == 2:
times = 0
else:
times = times + 1
last_step = 2
if thresh > 0:
thresh -= 1
else:
print("忽略")
break
mask = array2uint8(mask_copy)
first_frame = img_copy
cv2.namedWindow('mask res', cv2.WINDOW_NORMAL)
cv2.resizeWindow('mask res', 800, 450)
cv2.imshow('mask res', mask)
cv2.circle(first_frame, (x, y), 20, (0, 255, 0), 5)
cv2.namedWindow('first_frame', cv2.WINDOW_NORMAL)
cv2.resizeWindow('first_frame', 800, 450)
cv2.imshow('first_frame', first_frame)
cv2.waitKey(1)
设置泛洪起始位置
我们在通过运动比对后得到的运动层区域的基础上,设置最小步进,如果该点是运动层区域且还未被上一步的泛洪填充,就进行泛洪填充。
def floodFill_step(step):
lenx = len(flash_mask) # 长宽
leny = len(flash_mask[0])
for x in range(50, lenx): # 从50开始遍历,防止对画面边角进行泛洪填充
for y in range(50, leny):
# 判断是否满足步进长度
if x % step == 0 and y % step == 0 and flash_mask[x][y] == 255 and mask[x][y] != 255:
print("next:", y, x)
Fill_Pexl(y, x, (255, 0, 0), 1000, 200000)
理论上步进越小,该方法越准确,但为了运行速度设置为100像素。
最后处理
把泛洪填充结果叠加运动层,黑色区域即为障碍层:
结果大概是正确的,完成!
结果优化方向
可以发现结果大概是正确的,但存在某些问题:
填充部分不连续
地砖处有明显缝隙,部分地砖区域坑坑洼洼。
可以对泛洪填充原图像先进行高斯模糊,整体降低扩散容差,或对泛洪填充区域进行腐蚀和膨胀来消除。
# 高斯模糊
first_frame = cv2.GaussianBlur(curr_frame, (3, 3), 0)
# 腐蚀膨胀
# 定义结构元素(膨胀和腐蚀操作的内核)
kernel = np.ones((5, 5), np.uint8)
# 膨胀操作(填充散点)
mask = cv2.dilate(mask, kernel, iterations=1)
# 腐蚀操作(连接填充后的区域)
mask_fix = cv2.erode(mask, kernel, iterations=1)
扩散到了障碍层
左侧面包车部分识别错误。
在运动比对过程中,运动层的记录的不是精确的运动像素,而是大概的相邻运动像素组成的最小矩形,这是为了防止运动层的图像过于不连续和方便选取泛洪填充起始位置的无奈之举。
测试分析
发生错误代表性场景
我继续从b站挑选代表性场景:
在该视频中,快递员经过的区域都识别为非障碍物,但显然左右边墙在常规认知中属于障碍物,该算法会对运动区域无条件过滤掉,但从方法构思来看,效果是达到了目标的。
在该视频中,草坪和社区均出现识别失误,原因是因为该视频片段动态层区域过小,动态区域未完全经过背景层,该算法不适合识别时间过短或无人场景。
在该视频中,摄像机被运动物体碰撞引起了大幅度抖动,导致动态层识别严重错误,该算法适合画面稳定的场景。
结论
该算法在摄像头稳定且运动物体活跃的情况下效果显著。
完整代码
import cv2
import numpy as np
# 创建窗口并设置初始大小
cv2.namedWindow('Dynamic Region', cv2.WINDOW_NORMAL)
cv2.resizeWindow('Dynamic Region', 800, 450)
cv2.namedWindow('Occlusions', cv2.WINDOW_NORMAL)
cv2.resizeWindow('Occlusions', 800, 450)
cv2.namedWindow('bin_img', cv2.WINDOW_NORMAL)
cv2.resizeWindow('bin_img', 800, 450)
cv2.namedWindow('flash_area', cv2.WINDOW_NORMAL)
cv2.resizeWindow('flash_area', 800, 450)
cv2.namedWindow('Dynamic kernel', cv2.WINDOW_NORMAL)
cv2.resizeWindow('Dynamic kernel', 800, 450)
# 调用函数查找静止区域
file_path = '监控/001.mp4'
frame_height = 0
frame_width = 0
last_bin_img = [] # 二值化 图像最后一帧
first_frame = [] # 视频 第一帧
mask = [] # 洪水填充遮罩结果
flash_mask = [] # 动态区域遮罩结果
outcome = [] # 最终结果
def array2uint8(array):
mask8 = np.array(array, np.uint8)
lenx = len(mask8)
leny = len(mask8[0])
for x in range(lenx):
for y in range(leny):
if mask8[x][y] == 1:
mask8[x][y] = 255
return mask8
def Fill_Pexl(x, y, color, low, high):
global mask, first_frame
thresh = 5
img_copy = []
mask_copy = []
last_step = 0
times = 0
runtimes = 0
while True:
runtimes = runtimes + 1
img_copy = first_frame.copy()
mask_copy = mask.copy()
area = cv2.floodFill(img_copy, mask_copy, (x, y), color, (thresh,thresh,thresh), (thresh,thresh,thresh))
print(thresh, area[0])
if low < area[0] < high:
break
elif runtimes > 10 and (last_step == 2 or runtimes > 100):
break
elif times > 3 and last_step == 2:
break
elif low >= area[0]:
if last_step == 1:
times = 0
else:
times = times + 1
last_step = 1
thresh += 1
elif high <= area[0]:
if last_step == 2:
times = 0
else:
times = times + 1
last_step = 2
if thresh > 0:
thresh -= 1
else:
print("忽略")
break
mask = array2uint8(mask_copy)
first_frame = img_copy
cv2.namedWindow('mask res', cv2.WINDOW_NORMAL)
cv2.resizeWindow('mask res', 800, 450)
cv2.imshow('mask res', mask)
cv2.circle(first_frame, (x, y), 20, (0, 255, 0), 5)
cv2.namedWindow('first_frame', cv2.WINDOW_NORMAL)
cv2.resizeWindow('first_frame', 800, 450)
cv2.imshow('first_frame', first_frame)
cv2.waitKey(1)
def add_flash_area(x, y, w, h):
global flash_mask
# print(x, y, w, h)
for i in range(w):
for j in range(h):
flash_mask[y + j][x + i] = 255
def find_static_region(file_path):
global first_frame, frame_height, frame_width, flash_mask, outcome, last_bin_img
frame_num = 0
# 打开视频文件
video = cv2.VideoCapture(file_path)
# 获取视频帧的高度和宽度
frame_height = int(video.get(cv2.CAP_PROP_FRAME_HEIGHT))
frame_width = int(video.get(cv2.CAP_PROP_FRAME_WIDTH))
flash_mask = np.zeros((frame_height, frame_width), np.uint8)
outcome = np.zeros((frame_height, frame_width), np.uint8)
total_pixel = frame_height * frame_width
# print(frame_height, frame_width, "总像素:", total_pixel)
# 读取第一帧作为初始帧
ret, prev_frame = video.read()
while True:
# 逐帧读取视频
ret, curr_frame = video.read()
if frame_num == 0:
# first_frame = curr_frame
first_frame = cv2.GaussianBlur(curr_frame, (3, 3), 0)
# 如果视频读取结束,则跳出循环
if not ret:
break
# # 转换为灰度图像
gray_img = cv2.cvtColor(curr_frame, cv2.COLOR_BGR2GRAY)
# 计算全局阈值
_, bin_img = cv2.threshold(gray_img, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)
# 计算当前帧与初始帧的差异
diff = cv2.absdiff(prev_frame, curr_frame)
# 将差异图像转换为灰度图像
gray = cv2.cvtColor(diff, cv2.COLOR_BGR2GRAY)
# 对灰度图像进行阈值处理
_, thresh = cv2.threshold(gray, 70, 255, cv2.THRESH_BINARY)
# 定义结构元素(膨胀和腐蚀操作的内核)
kernel = np.ones((5, 5), np.uint8)
# 膨胀操作(填充散点)
dilated_img = cv2.dilate(thresh, kernel, iterations=1)
# 腐蚀操作(连接填充后的区域)
thresh = cv2.erode(dilated_img, kernel, iterations=1)
# 对二值图像进行腐蚀和膨胀操作,以去除噪声
kernel1 = cv2.getStructuringElement(cv2.MORPH_RECT, (31, 31))
bin_img = cv2.morphologyEx(bin_img, cv2.MORPH_OPEN, kernel1)
last_bin_img = bin_img
# 反转二值图像
thresh_inv = cv2.bitwise_not(bin_img)
# # 在数字区域添加白底
# bg_color = (255, 0, 0)
# cv2.rectangle(bin_img, (0, 0), (300, 80), bg_color, -1)
# cv2.putText(bin_img, str(1 - (black_pixels / total_pixel)), (50, 50), cv2.FONT_HERSHEY_SIMPLEX, 1, (0, 255, 0),
# 2, cv2.LINE_AA)
cv2.imshow('bin_img', bin_img)
cv2.imshow('Occlusions', thresh_inv)
rectangle_frame = curr_frame.copy()
# 在原始帧上绘制静止区域的边界框
contours, _ = cv2.findContours(thresh, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
for contour in contours:
(x, y, w, h) = cv2.boundingRect(contour)
if w > 50 and h > 50:
cv2.rectangle(rectangle_frame, (x, y), (x + w, y + h), (0, 255, 0), 2)
add_flash_area(x, y, w, h)
# cv2.rectangle(rectangle_frame, (x, y), (x + w, y + h), (0, 255, 0), 2)
# add_flash_area(x, y, w, h)
# 显示当前帧
cv2.imshow('Dynamic Region', rectangle_frame)
cv2.imshow('Dynamic kernel', thresh)
cv2.imshow('flash_area', flash_mask)
# 按下 'q' 键退出播放
if cv2.waitKey(1) & 0xFF in (ord('q'), 27):
break
# 将当前帧设为初始帧
if frame_num % 2 == 0:
prev_frame = curr_frame
frame_num = frame_num + 1
# cv2.imshow('prev_frame', curr_frame)
# 关闭视频文件和窗口
video.release()
cv2.destroyAllWindows()
def floodFill_step(step):
lenx = len(flash_mask)
leny = len(flash_mask[0])
for x in range(50, lenx):
for y in range(50, leny):
if x % step == 0 and y % step == 0 and flash_mask[x][y] == 255 and mask[x][y] != 255:
print("next:", y, x)
Fill_Pexl(y, x, (255, 0, 0), 1000, 200000)
def all_add():
global last_bin_img, mask, flash_mask, outcome
width, height = 800, 450
last_bin_img = cv2.resize(last_bin_img, (width, height))
mask = cv2.resize(mask, (width, height))
flash_mask = cv2.resize(flash_mask, (width, height))
# outcome = cv2.bitwise_or(last_bin_img, mask)
outcome = cv2.bitwise_or(mask, flash_mask)
# 计算黑色像素的数量
black_pixels = cv2.countNonZero(outcome)
num = black_pixels / (width * height)
print("障碍物占比:", num)
# 显示结果
cv2.imshow('Union', outcome)
cv2.imshow('last_bin_img', last_bin_img)
cv2.imshow('flash_mask', flash_mask)
cv2.waitKey(0)
cv2.destroyAllWindows()
find_static_region(file_path)
mask = np.zeros((frame_height + 2, frame_width + 2), np.uint8)
floodFill_step(100)
all_add()
# cv2.waitKey(5000)