Basic Image Operations
OpenCV's elementwise operations - addition, subtraction, blending, bitwise logic - run on the whole image in one C call, and each one has a subtle semantic difference from the NumPy operator of the same name: OpenCV saturates (clips at 0 and 255), while NumPy's uint8 arithmetic wraps. Choosing between cv2.add and + is the first real decision in image math, and the blending and border functions build on top of the elementwise core.
Addition: Wrapping vs Saturating
import cv2
import numpy as np
a = np.full((4, 4), 200, dtype="uint8")
b = np.full((4, 4), 80, dtype="uint8")
print((a + b)[0, 0]) # 24 - uint8 wraps: 280 mod 256
print(cv2.add(a, b)[0, 0]) # 255 - saturates at the range limit
# scaling then adding - the "boost brightness" idiom
bright = cv2.convertScaleAbs(a, alpha=1.0, beta=30)
print(bright[0, 0]) # 255 - scale by alpha, add beta, clip
cv2.add clips at 255, + wraps around - for pixel math you almost always want the clip. convertScaleAbs does abs(alpha * x + beta) with saturation in one call, the standard tool for brightness/contrast adjustments: beta shifts brightness, alpha scales contrast.
Blending Images
import cv2
img1 = cv2.imread("a.jpg")
img2 = cv2.imread("b.jpg")
# weighted blend: (1 - w) * img1 + w * img2
blend = cv2.addWeighted(img1, 0.5, img2, 0.5, 0)
cv2.imwrite("blend.jpg", blend)
# a gradient cross-fade across the width
h, w = img1.shape[:2]
mask = np.zeros((h, w), dtype="float32")
for x in range(w):
mask[:, x] = x / w
# per-pixel: img1 on the left fading to img2 on the right
out = (img1.astype("float32") * (1 - mask[:, :, None])
+ img2.astype("float32") * mask[:, :, None])
cv2.imwrite("crossfade.jpg", out.astype("uint8"))
addWeighted(img1, w1, img2, w2, gamma) computes w1*img1 + w2*img2 + gamma with saturation - the one-call dissolve. The cross-fade above is the array version for spatially varying weights, and it is the same pattern behind zoom transitions and image stitching seams.
Bitwise Operations and Masks
import cv2
import numpy as np
img = cv2.imread("photo.jpg")
mask = np.zeros(img.shape[:2], dtype="uint8")
# draw the region you want to keep
cv2.circle(mask, (100, 100), 60, 255, -1) # filled white disc
# keep only the masked region
cutout = cv2.bitwise_and(img, img, mask=mask)
cv2.imwrite("cutout.jpg", cutout)
# the inverse: keep everything EXCEPT the disc
inv = cv2.bitwise_not(mask)
rest = cv2.bitwise_and(img, img, mask=inv)
The bitwise functions (bitwise_and, bitwise_or, bitwise_xor, bitwise_not) take an optional mask - a single-channel 0/255 array that restricts the operation to the non-zero pixels. bitwise_and(img, img, mask) is the standard "cut out this region" idiom, and it reappears constantly: masking out backgrounds, combining detections, painting only where a condition holds.
Borders
import cv2
import numpy as np
img = cv2.imread("photo.jpg")
# mirror padding - the default for edge-aware filters
padded = cv2.copyMakeBorder(img, 20, 20, 20, 20,
cv2.BORDER_REFLECT_101)
print(img.shape, padded.shape) # grew by 40 in both dimensions
# constant padding with a chosen color (e.g. letterboxing)
pad = cv2.copyMakeBorder(img, 10, 10, 10, 10,
cv2.BORDER_CONSTANT, value=(0, 0, 0))
copyMakeBorder adds a border with a specified mode: constant, replicate (edge values), reflect, or wrap. Beyond the obvious framing use, padding is how you control what a filter sees at the image edges - most blurs offer a borderType for exactly this, and explicit padding is the visible version of the same choice.
Summary
Do image arithmetic with the saturating OpenCV functions - cv2.add, cv2.subtract, convertScaleAbs - not the wrapping NumPy operators. Blend with addWeighted (one-call dissolve) or per-pixel float math for spatially varying weights, cut out regions with bitwise_and(img, img, mask) - the mask idiom that runs through the whole library - and pad with copyMakeBorder when edge behavior matters. The unifying idea: every operation is elementwise over the array, and the mask is just another channel that says "only here."