使用OpenCV进行视频的Alpha混合

3

我想使用alpha视频将一个视频混合到另一个视频的顶部。这是我的代码。它完美地工作,但问题在于这个代码根本不高效,这是因为/255部分。它很慢,有延迟问题。

是否有一种标准且高效的方法来完成这个任务?我希望结果是实时的。谢谢。

import cv2
import numpy as np

def main():
    foreground = cv2.VideoCapture('circle.mp4')
    background = cv2.VideoCapture('video.MP4')
    alpha = cv2.VideoCapture('circle_alpha.mp4')

    while foreground.isOpened():
        fr_foreground = foreground.read()[1]/255
        fr_background = background.read()[1]/255     
        fr_alpha = alpha.read()[1]/255

        cv2.imshow('My Image',cmb(fr_foreground,fr_background,fr_alpha))

        if cv2.waitKey(1) == ord('q'): break

    cv2.destroyAllWindows

def cmb(fg,bg,a):
    return fg * a + bg * (1-a)

if __name__ == '__main__':
    main()

1
尝试分别测量读取时间和处理时间,以确保知道需要改进的是什么。 - Mark Setchell
1
我猜你正在使用Python 3.x,因为在2.x中,“/255”无法以相同的方式工作... - Dan Mašek
3个回答

6

首先,让我们解决一些明显的问题 - foreground.isOpened() 即使在视频结束后也会返回 true,因此您的程序最终会崩溃。解决方案是双重的。首先,在创建所有 3 个 VideoCapture 实例后立即测试它们,使用类似以下内容的东西:

if not foreground.isOpened() or not background.isOpened() or not alpha.isOpened():
    print "Unable to open input videos."
    return

这将确保它们全部正确打开。接下来的部分是正确处理视频结束。这意味着要么检查read()的两个返回值中的第一个,它是表示成功的布尔标志,要么测试帧是否为None

while True:
    r_fg, fr_foreground = foreground.read()
    r_bg, fr_background = background.read()
    r_a, fr_alpha = alpha.read()
    if not r_fg or not r_bg or not r_a:
        break # End of video

此外,似乎您实际上没有调用cv2.destroyAllWindows() -- 缺少()。虽然这并不重要。
为了帮助调查和优化这个问题,我添加了一些详细的时间记录,使用了timeit模块和一些方便的函数。
from timeit import default_timer as timer

def update_times(times, total_times):
    for i in range(len(times) - 1):
        total_times[i] += (times[i+1]-times[i]) * 1000

def print_times(total_times, n):
    print "Iterations: %d" % n
    for i in range(len(total_times)):
        print "Step %d: %0.4f ms" % (i, total_times[i] / n)
    print "Total: %0.4f ms" % (np.sum(total_times) / n)

我修改了main()函数,以测量每个逻辑步骤所需的时间——读取、缩放、混合、显示、等待按键。为此,我将除法拆分为单独的语句。我还进行了轻微修改,使其在Python 2.x中也能正常工作(/255被解释为整数除法并产生错误结果)。
times = [0.0] * 6
total_times = [0.0] * (len(times) - 1)
n = 0
while True:
    times[0] = timer()
    r_fg, fr_foreground = foreground.read()
    r_bg, fr_background = background.read()
    r_a, fr_alpha = alpha.read()
    if not r_fg or not r_bg or not r_a:
        break # End of video
    times[1] = timer()
    fr_foreground = fr_foreground / 255.0
    fr_background = fr_background / 255.0
    fr_alpha = fr_alpha / 255.0
    times[2] = timer()
    result = cmb(fr_foreground,fr_background,fr_alpha)
    times[3] = timer()
    cv2.imshow('My Image', result)
    times[4] = timer()
    if cv2.waitKey(1) == ord('q'): break
    times[5] = timer()
    update_times(times, total_times)
    n += 1

print_times(total_times, n)

当我使用1280x800的mp4视频作为输入时,我发现它非常缓慢,并且在我的6核机器上仅使用15%的CPU。各部分的时间如下:
Iterations: 1190
Step 0: 11.4385 ms
Step 1: 37.1320 ms
Step 2: 39.4083 ms
Step 3: 2.5488 ms
Step 4: 10.7083 ms
Total: 101.2358 ms

这表明最大的瓶颈在于缩放步骤和混合步骤。低CPU使用率也不够理想,但让我们先专注于易于解决的问题。
让我们看一下我们使用的numpy数组的数据类型。read()给出的数组具有np.uint8的dtype - 8位无符号整数。然而,浮点除法(如所写)将产生一个具有np.float64的dtype的数组 - 64位浮点值。对于我们的算法,我们实际上不需要这种精度水平,因此最好只使用32位浮点数 - 这意味着如果任何操作是矢量化的,我们可以在相同的时间内潜在地进行两倍的计算。
这里有两个选项。我们可以简单地将除数强制转换为np.float32,这将导致numpy给出具有相同dtype的结果:
fr_foreground = fr_foreground / np.float32(255.0)
fr_background = fr_background / np.float32(255.0)
fr_alpha = fr_alpha / np.float32(255.0)

这给我们以下计时:

Iterations: 1786
Step 0: 9.2550 ms
Step 1: 19.0144 ms
Step 2: 21.2120 ms
Step 3: 1.4662 ms
Step 4: 10.8889 ms
Total: 61.8365 ms

或者我们可以先将数组转换为np.float32,然后在原地进行缩放。

fr_foreground = np.float32(fr_foreground)
fr_background = np.float32(fr_background)
fr_alpha = np.float32(fr_alpha)

fr_foreground /= 255.0
fr_background /= 255.0
fr_alpha /= 255.0

以下是时间(将步骤1分为转换(1)和缩放(2) - 其余移动1):
Iterations: 1786
Step 0: 9.0589 ms
Step 1: 13.9614 ms
Step 2: 4.5960 ms
Step 3: 20.9279 ms
Step 4: 1.4631 ms
Step 5: 10.4396 ms
Total: 60.4469 ms

两者大致相当,运行时间为原始时间的约60%。我会选择第二个选项,因为它在后续步骤中会变得有用。让我们看看还有什么可以改进。
从之前的时间测试中,我们可以看出缩放不再是瓶颈,但仍有一个想法浮现:除法通常比乘法慢,那么如果我们乘以倒数会怎样?
fr_foreground *= 1/255.0
fr_background *= 1/255.0
fr_alpha *= 1/255.0

实际上,这确实为我们节省了一毫秒时间——虽然没有什么惊人之处,但很容易实现,所以不妨采用它:

Iterations: 1786
Step 0: 9.1843 ms
Step 1: 14.2349 ms
Step 2: 3.5752 ms
Step 3: 21.0545 ms
Step 4: 1.4692 ms
Step 5: 10.6917 ms
Total: 60.2097 ms

现在混合函数是最大的瓶颈,其次是对所有3个数组的类型转换。如果我们看一下混合操作的作用:

foreground * alpha + background * (1.0 - alpha)

我们可以观察到,为了让数学运算起作用,唯一需要在范围(0.0,1.0)内的值是alpha
如果我们只缩放alpha图像呢?另外,由于浮点数乘法会提升为浮点数,如果我们也跳过类型转换会怎样?这意味着cmb()将返回np.uint8数组。
def cmb(fg,bg,a):
    return np.uint8(fg * a + bg * (1-a))

我们会有

    #fr_foreground = np.float32(fr_foreground)
    #fr_background = np.float32(fr_background)
    fr_alpha = np.float32(fr_alpha)

    #fr_foreground *= 1/255.0
    #fr_background *= 1/255.0
    fr_alpha *= 1/255.0

这个的时间是

Step 0: 7.7023 ms
Step 1: 4.6758 ms
Step 2: 1.1061 ms
Step 3: 27.3188 ms
Step 4: 0.4783 ms
Step 5: 9.0027 ms
Total: 50.2840 ms

显然,步骤1和步骤2要快得多,因为我们只完成了1/3的工作。 imshow 也加速了,因为它不必从浮点数转换。出乎意料的是,读取速度也加快了(我猜我们避免了一些在幕后重新分配内存,因为 fr_foregroundfr_background 总是包含完好无损的帧)。我们在 cmb() 中付出了额外的转换代价,但总体而言这似乎是一种胜利——我们现在只需要原先时间的50%。


为了继续,让我们摆脱函数,将其功能移动到main()并分割它以测量每个操作的成本。让我们也尝试重用alpha.read()的结果(因为我们最近看到了read()性能的提高):
times = [0.0] * 11
total_times = [0.0] * (len(times) - 1)
n = 0
while True:
    times[0] = timer()
    r_fg, fr_foreground = foreground.read()
    r_bg, fr_background = background.read()
    r_a, fr_alpha_raw = alpha.read()
    if not r_fg or not r_bg or not r_a:
        break # End of video

    times[1] = timer()
    fr_alpha = np.float32(fr_alpha_raw)
    times[2] = timer()
    fr_alpha *= 1/255.0
    times[3] = timer()
    fr_alpha_inv = 1.0 - fr_alpha
    times[4] = timer()
    fr_fg_weighed = fr_foreground * fr_alpha
    times[5] = timer()
    fr_bg_weighed = fr_background * fr_alpha_inv
    times[6] = timer()
    sum = fr_fg_weighed + fr_bg_weighed
    times[7] = timer()
    result = np.uint8(sum)
    times[8] = timer()
    cv2.imshow('My Image', result)
    times[9] = timer()
    if cv2.waitKey(1) == ord('q'): break
    times[10] = timer()
    update_times(times, total_times)
    n += 1

新时间:
Iterations: 1786
Step 0: 6.8733 ms
Step 1: 5.2742 ms
Step 2: 1.1430 ms
Step 3: 4.5800 ms
Step 4: 7.0372 ms
Step 5: 7.0675 ms
Step 6: 5.3082 ms
Step 7: 2.6912 ms
Step 8: 0.4658 ms
Step 9: 9.6966 ms
Total: 50.1372 ms

我们没有真正获得任何东西,但读取速度明显加快了。
这引出了另一个想法——如果我们尝试最小化分配并在后续迭代中重复使用数组会怎样?
我们可以在第一次迭代中预先分配必要的数组(使用numpy.zeros_like),在读取第一组帧后:
if n == 0: # Pre-allocate
    fr_alpha = np.zeros_like(fr_alpha_raw, np.float32)
    fr_alpha_inv = np.zeros_like(fr_alpha_raw, np.float32)
    fr_fg_weighed = np.zeros_like(fr_alpha_raw, np.float32)
    fr_bg_weighed = np.zeros_like(fr_alpha_raw, np.float32)
    sum = np.zeros_like(fr_alpha_raw, np.float32)
    result = np.zeros_like(fr_alpha_raw, np.uint8)

现在,我们可以使用以下函数: 我们还可以将步骤1和2合并为一个单一的numpy.multiply
times = [0.0] * 10
total_times = [0.0] * (len(times) - 1)
n = 0
while True:
    times[0] = timer()
    r_fg, fr_foreground = foreground.read()
    r_bg, fr_background = background.read()
    r_a, fr_alpha_raw = alpha.read()
    if not r_fg or not r_bg or not r_a:
        break # End of video

    if n == 0: # Pre-allocate
        fr_alpha = np.zeros_like(fr_alpha_raw, np.float32)
        fr_alpha_inv = np.zeros_like(fr_alpha_raw, np.float32)
        fr_fg_weighed = np.zeros_like(fr_alpha_raw, np.float32)
        fr_bg_weighed = np.zeros_like(fr_alpha_raw, np.float32)
        sum = np.zeros_like(fr_alpha_raw, np.float32)
        result = np.zeros_like(fr_alpha_raw, np.uint8)

    times[1] = timer()
    np.multiply(fr_alpha_raw, np.float32(1/255.0), fr_alpha)
    times[2] = timer()
    np.subtract(1.0, fr_alpha, fr_alpha_inv)
    times[3] = timer()
    np.multiply(fr_foreground, fr_alpha, fr_fg_weighed)
    times[4] = timer()
    np.multiply(fr_background, fr_alpha_inv, fr_bg_weighed)
    times[5] = timer()
    np.add(fr_fg_weighed, fr_bg_weighed, sum)
    times[6] = timer()
    np.copyto(result, sum, 'unsafe')
    times[7] = timer()
    cv2.imshow('My Image', result)
    times[8] = timer()
    if cv2.waitKey(1) == ord('q'): break
    times[9] = timer()
    update_times(times, total_times)
    n += 1

这给我们以下时间:
Iterations: 1786
Step 0: 7.0515 ms
Step 1: 3.8839 ms
Step 2: 1.9080 ms
Step 3: 4.5198 ms
Step 4: 4.3871 ms
Step 5: 2.7576 ms
Step 6: 1.9273 ms
Step 7: 0.4382 ms
Step 8: 7.2340 ms
Total: 34.1074 ms

我们在所有修改的步骤中都取得了显著的改进。现在,我们所需时间仅为原始实现所需时间的约35%。


小更新:

根据Silencer回答,我也测量了cv2.convertScaleAbs。实际上它运行得更快:

Step 6: 1.2318 ms

这给了我另一个想法——我们可以利用cv2.add,它允许我们指定目标数据类型并进行饱和转换。这将使我们能够将步骤5和6合并在一起。

cv2.add(fr_fg_weighed, fr_bg_weighed, result, dtype=cv2.CV_8UC3)

这是输出结果

Step 5: 3.3621 ms

我们又获得了一点胜利(之前我们大约是3.9毫秒)。

接下来,cv2.subtractcv2.multiply 是更进一步的候选项。我们需要使用一个四元组来定义标量(这是Python绑定的复杂性),并且我们需要明确定义乘法的输出数据类型。

    cv2.subtract((1.0, 1.0, 1.0, 0.0), fr_alpha, fr_alpha_inv)
    cv2.multiply(fr_foreground, fr_alpha, fr_fg_weighed, dtype=cv2.CV_32FC3)
    cv2.multiply(fr_background, fr_alpha_inv, fr_bg_weighed, dtype=cv2.CV_32FC3)

时间:

Step 2: 2.1897 ms
Step 3: 2.8981 ms
Step 4: 2.9066 ms

这似乎是我们在没有一些并行化的情况下所能达到的最远程度。我们已经利用了OpenCV在个别操作方面提供的优势,因此我们应该专注于将我们的实现进行管道化。
为了帮助我确定如何在不同的管道阶段(线程)之间划分代码,我制作了一张图表,显示了所有操作、我们的最佳时间以及计算的相互依赖关系:

enter image description here

正在进行中,请查看注释获取更多信息,同时我会将其撰写出来。


并行化明天就会到来,现在有点晚了。 - Dan Mašek
1
非常感谢@DanMašek。回放FPS从5提高到了16。然而,它应该至少达到24,但我认为并行化可以增加代码的速度。我期待着您关于并行化的回复。 - sd70
1
@DanMašek 第二部分在哪里??!! - Jeru Luke
1
@JeruLuke 感谢您的关注(我看了一下您的个人资料,也感谢您)。准备和撰写这篇文章需要花费相当长的时间,而且我还有一些真正的工作要做。技术上讲,我是在凌晨某个可怕的时间写下这条评论的,所以直到2小时前它仍然是“明天”... :D | 无论如何,我现在已经达到了每帧10毫秒的速度,但 waitKey 成为了瓶颈(在 Windows 上有 sleep),因此为了保持可视化效果,跳过了大约30%的处理帧。我正在努力撰写一些相关的文本,并会随时更新给您。 :) | https://pastebin.com/EzCy6bQK - Dan Mašek
很想知道在多线程下进行软件混合是否对4K视频有所帮助?这就是我的方法开始受到影响的地方,因为两个图层需要巨大的58MB传输。 - mainactual
我正在关注你的pastebin进展 - 我很惊讶通过队列发送大量数据会如此有效,并且不需要某种共享内存 - 当然,我不知道Python在队列下面是如何实现的...我猜它可能只是指针。 - Mark Setchell

1
我正在使用 OpenCV 4.00-prePython 3.6
  1. 不需要进行三个 xxx/255 的操作,只有 alpha 是可以的。
  2. 注意类型转换,更倾向于使用 cv2.convertScaleAbs(xxx) 而不是 np.uint8(xxx)np.copyto(xxx,yyy, "unsafe")
  3. 预分配内存应该更好。

我使用 #2,即 cv2.convertScaleAbs 来避免 underflow/overflow,范围在[0,255]。例如:

>>> x = np.array([[-1,256]])
>>> y = np.uint8(x)
>>> z = cv2.convertScaleAbs(x)
>>> x
array([[ -1, 256]])
>>> y
array([[255,   0]], dtype=uint8)
>>> z
array([[  1, 255]], dtype=uint8)

##! 2018/05/09 13:54:34

import cv2
import numpy as np
import time

def cmb(fg,bg,a):
    return fg * a + bg * (1-a)

def test2():
    cap = cv2.VideoCapture(0)
    ret, prev_frame = cap.read()
    """
    foreground = cv2.VideoCapture('circle.mp4')
    background = cv2.VideoCapture('video.MP4')
    alphavideo = cv2.VideoCapture('circle_alpha.mp4')
    """
    while cap.isOpened():
        ts = time.time()
        ret, fg = cap.read()
        alpha = fg.copy()
        bg = prev_frame
        """
        ret, fg = foreground.read()
        ret, bg = background.read()
        ret, alpha = alphavideo.read()
        """

        alpha = np.multiply(alpha, 1.0/255)
        blended = cv2.convertScaleAbs(cmb(fg, bg, alpha))
        te = time.time()
        dt = te-ts
        fps = 1/dt
        print("{:.3}ms, {:.3} fps".format(1000*dt, fps))
        cv2.imshow('Blended', blended)

        if cv2.waitKey(1) == ord('q'):
            break

    cv2.destroyAllWindows()

if __name__ == "__main__":
    test2()

一些类似这样的输出:
39.0ms, 25.6 fps
37.0ms, 27.0 fps
38.0ms, 26.3 fps
37.0ms, 27.0 fps
38.0ms, 26.3 fps
37.0ms, 27.0 fps
38.0ms, 26.3 fps
37.0ms, 27.0 fps
37.0ms, 27.0 fps
37.0ms, 27.0 fps
37.0ms, 27.0 fps
38.0ms, 26.3 fps
37.0ms, 27.0 fps
37.0ms, 27.0 fps
37.0ms, 27.0 fps
37.0ms, 27.0 fps
...

谢谢@silencer我按照你的建议重写了代码,但FPS只有6,速度很慢。这是我的新代码:code.py - sd70
第二点背后的原因是什么?(我还没有考虑过检查这个) - Dan Mašek
1
我使用 #2,也就是 cv2.convertScaleAbs 来避免 underflow/overflow,范围在 [0, 255]。 - Kinght 金
@Silencer 说得好,虽然我不认为这里有危险。尽管如此,它似乎表现得更好,并且让我尝试了一些其他的东西,所以+1。 - Dan Mašek

1
如果只是混合、渲染和忘记,那么在GPU上进行操作就很有意义。VTK(可视化工具包)(https://www.vtk.org)可以为您完成此操作,而不是使用imshow。VTK已经出现在OpenCV 3D可视化器模块中(https://docs.opencv.org/3.2.0/d1/d19/group__viz.html),因此不应该增加太多依赖。

此后整个计算部分(除了读取视频帧)都归结为cv2.mixChannels和像素数据传输到两个渲染器,对于1280x720的视频,在我的电脑上每次迭代大约需要5毫秒。

import sys
import cv2
import numpy as np
import vtk
from vtk.util import numpy_support
import time

class Renderer:
    # VTK renderer with two layers
    def __init__( self ):
        self.layer1 = vtk.vtkRenderer()
        self.layer1.SetLayer(0)
        self.layer2 = vtk.vtkRenderer()
        self.layer2.SetLayer(1)
        self.renWin = vtk.vtkRenderWindow()
        self.renWin.SetNumberOfLayers( 2 )
        self.renWin.AddRenderer(self.layer1)
        self.renWin.AddRenderer(self.layer2)
        self.iren = vtk.vtkRenderWindowInteractor()
        self.iren.SetRenderWindow(self.renWin)
        self.iren.Initialize()      
    def Render( self ):
        self.iren.Render()

# set background image to a given renderer (resets the camera)
# from https://www.vtk.org/Wiki/VTK/Examples/Cxx/Images/BackgroundImage
def SetBackground( ren, image ):    
    bits = numpy_support.numpy_to_vtk( image.ravel() )
    bits.SetNumberOfComponents( image.shape[2] )
    bits.SetNumberOfTuples( bits.GetNumberOfTuples()/bits.GetNumberOfComponents() )

    img = vtk.vtkImageData()
    img.GetPointData().SetScalars( bits );
    img.SetExtent( 0, image.shape[1]-1, 0, image.shape[0]-1, 0,0 );
    origin = img.GetOrigin()
    spacing = img.GetSpacing()
    extent = img.GetExtent()

    actor = vtk.vtkImageActor()
    actor.SetInputData( img )

    ren.RemoveAllViewProps()
    ren.AddActor( actor )
    camera = vtk.vtkCamera()
    camera.ParallelProjectionOn()
    xc = origin[0] + 0.5*(extent[0] + extent[1])*spacing[0]
    yc = origin[1] + 0.5*(extent[2] + extent[3])*spacing[1]
    yd = (extent[3] - extent[2] + 1)*spacing[1]
    d = camera.GetDistance()
    camera.SetParallelScale(0.5*yd)
    camera.SetFocalPoint(xc,yc,0.0)
    camera.SetPosition(xc,yc,-d)
    camera.SetViewUp(0,-1,0)
    ren.SetActiveCamera( camera )
    return img

# update the scalar data without bounds check
def UpdateImageData( vtkimage, image ):
    bits = numpy_support.numpy_to_vtk( image.ravel() )
    bits.SetNumberOfComponents( image.shape[2] )
    bits.SetNumberOfTuples( bits.GetNumberOfTuples()/bits.GetNumberOfComponents() )
    vtkimage.GetPointData().SetScalars( bits );

r = Renderer()
r.renWin.SetSize(1280,720)
cap = cv2.VideoCapture('video.mp4')
image = cv2.imread('hello.png',1)
alpha = cv2.cvtColor(image,cv2.COLOR_RGB2GRAY )
ret, alpha = cv2.threshold( alpha, 127, 127, cv2.THRESH_BINARY )
alpha = np.reshape( alpha, (alpha.shape[0],alpha.shape[1], 1 ) )

src1=[]
src2=[]
overlay=[]
c=0
while ( 1 ):
    # read the data
    ret, mat = cap.read()
    if ( not ret ):
        break
    #TODO ret, image = cap2.read() #(rgb)
    #TODO ret, alpha = cap3.read() #(mono)

    # alpha blend
    t=time.time()
    if ( overlay==[] ):
        overlay = np.zeros( [image.shape[0],image.shape[1],4], np.uint8 ) 
    cv2.mixChannels( [image, alpha], [overlay], [0,0,1,1,2,2,3,3] )
    if ( src1==[] ):
        src1 = SetBackground( r.layer1, mat )
    else:
        UpdateImageData( src1, mat )
    if ( src2==[] ):
        src2 = SetBackground( r.layer2, overlay )
    else:
        UpdateImageData( src2, overlay )
    r.Render()
    # blending done
    t = time.time()-t;

    if ( c % 10 == 0 ):
        print 1000*t
    c = c+1;

不错,如果您像原帖中那样使用3个输入视频,效果会更有意义--即使只是3个相同文件的副本也会产生有意义的结果(与自身混合),并且还可以给您提供有意义的时间。 - Dan Mašek
1
@DanMašek,但我在每次迭代中都会执行mixChannels,就好像alpha和叠加图像在每次迭代中都被更改一样。时间只是用于alpha混合,因为我的硬盘性能并不是很有趣,对吧?此外,我不得不使用MJPG-avi;三个这样的文件将消耗我所有的帧时间才能开始 :) - mainactual
谢谢@mainactual,我之前没有使用过VTK,需要阅读相关资料才能理解你的代码。再次感谢。 - sd70
@sd70 试一下吧 :) 或者如果你喜欢的UI库支持RGBA格式和z-order(例如Qt QGraphicsScene),在mixChannels步骤之后同样可以实现。 - mainactual
@mainactual 说得好,不过只要你运行一次,硬盘的问题就会消失了,数据会被操作系统缓存(至少在我这里是这样观察到的)。 - Dan Mašek

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接