Using the alpha channel as a binary mask
import cv2
import numpy as np
#create 3 separate BGRA images as our "layers"
layer1 = np.zeros((500, 500, 4))
layer2 = np.zeros((500, 500, 4))
layer3 = np.zeros((500, 500, 4))
#draw a red circle on the first "layer",
#a green rectangle on the second "layer",
#a blue line on the third "layer"
red_color = (0, 0, 255, 255)
green_color = (0, 255, 0, 255)
blue_color = (255, 0, 0, 255)
cv2.circle(layer1, (255, 255), 100, red_color, 5)
cv2.rectangle(layer2, (175, 175), (335, 335), green_color, 5)
cv2.line(layer3, (170, 170), (340, 340), blue_color, 5)
res = layer1[:] #copy the first layer into the resulting image
#copy only the pixels we were drawing on from the 2nd and 3rd layers
#(if you don't do this, the black background will also be copied)
cnd = layer2[:, :, 3] > 0
res[cnd] = layer2[cnd]
cnd = layer3[:, :, 3] > 0
res[cnd] = layer3[cnd]
cv2.imwrite("out.png", res)

With proper (or so I hope) alpha blending
The equations are taken from this Wikipedia page (the "over" operator in the "Description" section). It does not mention how to avoid possible divisions by 0 resulting alpha values in the second equation, but my common sense tells me that would only be the case if both source alpha values are 0, and therefore the resulting BGR should be 0, and that is confirmed by the Japanese Wikipedia page.
import cv2
import numpy as np
def blend(img_foreground, img_background):
'''
Alpha blending the foreground image on top of the background.
Both should be BGRA.
The value of each resulting pixel becomes
A = A_foreground + A_background * (1 - A_foreground)
BGR = (BGR_foreground * A_foreground + BGR_background * A_background * (1 - A_foreground)) / A
If A is 0, BGR is [0, 0, 0].
'''
# blending the normalized alpha components
alpha_a_norm = img_foreground[:, :, 3].astype(np.float32) / 255.0
alpha_b_norm = img_background[:, :, 3].astype(np.float32) / 255.0
alpha_o_norm = alpha_a_norm + alpha_b_norm * (1 - alpha_a_norm)
# reshape alpha arrays to multiply BGR components
aan = alpha_a_norm[:, :, np.newaxis]
abn = alpha_b_norm[:, :, np.newaxis]
aon = alpha_o_norm.copy()[:, :, np.newaxis] # going to modify, changing 0s to 1s, so use a copy instead of a view
# blending the BGR components
color_a = img_foreground[:, :, :3]
color_b = img_background[:, :, :3]
# this divisor may be 0, but only when both source's alphas are 0,
# and the resulting color is, therefore, also 0.
# to avoid invalid divisions or further checks for 0,
# let's replace all 0-s to 1-s, so the division has no effect
aon[aon == 0] = 1
color_o = (color_a * aan + color_b * abn * (1 - aan)) / aon
# de-normalizing resulting alpha back to 0~255, adding to the resulting BGR
alpha_result = alpha_o_norm * 255
result = np.dstack((color_o, alpha_result))
return result.astype(np.uint8)
# create 3 separate BGRA images as our "layers"
layer1 = np.zeros((500, 500, 4))
layer2 = np.zeros((500, 500, 4))
layer3 = np.zeros((500, 500, 4))
# create 3 colors of different opacity
red_color = (0, 0, 255, 255) # red, fully opaque
green_color = (0, 255, 0, 127) # green, half-opaque
blue_color = (255, 0, 0, 64) # blue, quarter-opaque
# draw 3 shapes with our colors
cv2.circle(layer1, (255, 255), 100, red_color, 5)
cv2.rectangle(layer2, (175, 175), (335, 335), green_color, 5)
cv2.line(layer3, (170, 170), (340, 340), blue_color, 5)
# create a fully transparent image to blend on top of
res = np.full(shape=(500, 500, 4), fill_value=0, dtype=np.uint8)
# alternatively, to draw on top of an existing image,
# that image has to have an alpha channel, too,
# or we have to add it:
#res = cv2.imread("an_500x500_RGB_image.jpg")
#alpha = np.full((500, 500), 255, dtype=np.uint8)
#res = np.dstack((res, alpha))
# blend the "layers" one by one
res = blend(layer1, res)
res = blend(layer2, res)
res = blend(layer3, res)
cv2.imwrite("out.png", res)

