NumPy Image Processing¶
A detailed reference covering every core concept for working with images as NumPy arrays.
from __future__ import annotations
from matplotlib.axes import Axes
from matplotlib.figure import Figure
import matplotlib.pyplot as plt
import numpy as np
from PIL import Image
from scipy.ndimage import binary_closing
from scipy.ndimage import binary_dilation
from scipy.ndimage import binary_erosion
from scipy.ndimage import binary_opening
from scipy.ndimage import convolve
from scipy.ndimage import gaussian_filter
from scipy.ndimage import label
from scipy.ndimage import sobel
from scipy.ndimage import zoom
IMAGE_PATH = "../../../static/images/dl/shivangi_joshi.jpeg"
img = np.array(Image.open(IMAGE_PATH).convert("RGB"))
print(f"Image loaded — shape: {img.shape}, dtype: {img.dtype}")
Image loaded — shape: (576, 460, 3), dtype: uint8
def show(
*images: np.ndarray,
titles: list[str] | None = None,
cmap: str | None = None,
) -> None:
"""Display one or more images side-by-side."""
n: int = len(images)
fig: Figure
axes: Axes | np.ndarray
fig, axes = plt.subplots(1, n, figsize=(5 * n, 5))
# type: ignore[arg-type]
axes_list: list[Axes] = [axes] if n == 1 else list(axes)
image: np.ndarray
title: str
ax: Axes
for ax, image, title in zip(axes_list, images, titles or [""] * n):
c: str | None = cmap if image.ndim == 2 else None
ax.imshow(image, cmap=c or "gray")
ax.set_title(title, fontsize=11)
ax.axis("off")
plt.tight_layout()
plt.show()
Image as a NumPy Array¶
What is an image, numerically?¶
A digital image is a rectangular grid of pixels. Each pixel stores colour information as numbers. NumPy represents this as a multi-dimensional array where every element is a pixel component value.
Shape and dimensions¶
| Image type | Array shape | Example |
|---|---|---|
| Colour (RGB) | (height, width, 3) |
(768, 1024, 3) |
| Grayscale | (height, width) |
(768, 1024) |
| RGBA (with transparency) | (height, width, 4) |
(768, 1024, 4) |
Important: the axis order is always
(height, width), not(width, height). Height = rows = Y axis. Width = columns = X axis.
Data type (dtype)¶
Images are usually stored as uint8 — unsigned 8-bit integers. Each channel value is an integer in the range 0 to 255.
0= darkest (no light in that channel)255= brightest (full intensity in that channel)
# Shape, dtype, and basic info
print(f"Shape : {img.shape}")
print(f"Dtype : {img.dtype}")
print(f"Min : {img.min()}")
print(f"Max : {img.max()}")
print(f"Type : {type(img)}")
Shape : (576, 460, 3) Dtype : uint8 Min : 0 Max : 255 Type : <class 'numpy.ndarray'>
show(img, titles=[f"Original {img.shape}"])
Accessing pixels
pixel = img[100, 200] # single pixel → array([R, G, B])
print(f"Pixel at [100, 200] : {pixel}")
Pixel at [100, 200] : [128 85 68]
row_50 = img[50, :, :] # all columns in row 50, shape: (W, 3)
col_50 = img[:, 50, :] # all rows in column 50, shape: (H, 3)
print(f"Row 50 shape : {row_50.shape}")
print(f"Col 50 shape : {col_50.shape}")
Row 50 shape : (460, 3) Col 50 shape : (576, 3)
The uint8 overflow trap
arr = np.array([200, 250], dtype=np.uint8)
print(f"uint8 + 100 (overflow) : {arr + 100}") # WRONG — wraps around
uint8 + 100 (overflow) : [44 94]
tmp = arr.astype(np.float32)
safe = np.clip(tmp + 100, 0, 255).astype(np.uint8)
print(f"float32 + 100, clipped : {safe}") # correct
float32 + 100, clipped : [255 255]
Creating images from scratch
h, w = img.shape[:2]
black = np.zeros((h, w, 3), dtype=np.uint8)
white = np.full((h, w, 3), 255, dtype=np.uint8)
noise = np.random.randint(0, 256, (h, w, 3), dtype=np.uint8)
blue = np.zeros((h, w, 3), dtype=np.uint8)
blue[:, :, 2] = 255
show(black,
white,
noise,
blue,
titles=["Black", "White", "Noise", "Solid Blue"])
Slicing and Cropping¶
NumPy slicing uses the syntax array[start:stop:step] along each axis. For a 3D image:
img[row_start:row_end, col_start:col_end, channel_start:channel_end]
Slicing returns a view, not a copy — changes to the slice affect the original. Use
.copy()when you need an independent array.
Basic Crops
h, w = img.shape[:2]
top = img[:h // 2, :, :]
bottom = img[h // 2:, :, :]
left = img[:, :w // 2, :]
right = img[:, w // 2:, :]
show(
img,
top,
bottom,
left,
right,
titles=["Original", "Top half", "Bottom half", "Left half", "Right half"],
)
Centre Crop
def centre_crop(img: np.ndarray, crop_h: np.uint8,
crop_w: np.uint8) -> np.ndarray:
h, w = img.shape[:2]
y_offset = (h - crop_h) // 2
x_offset = (w - crop_w) // 2
return img[y_offset:y_offset + crop_h, x_offset:x_offset + crop_w]
crop_size = min(h, w) // 2
centre = centre_crop(img, crop_size, crop_size)
show(img, centre, titles=["Original", f"Centre crop ({crop_size}×{crop_size})"])
Downsampling with step slicing
half = img[::2, ::2] # every 2nd pixel → 50% size
quarter = img[::4, ::4] # every 4th pixel → 25% size
print(f"Original : {img.shape}")
print(f"Half : {half.shape}")
print(f"Quarter : {quarter.shape}")
Original : (576, 460, 3) Half : (288, 230, 3) Quarter : (144, 115, 3)
show(
img,
half,
quarter,
titles=[
f"Original {img.shape[:2]}", f"Half {half.shape[:2]}",
f"Quarter {quarter.shape[:2]}"
],
)
Padding
pad = 30
padded_black = np.pad(img, ((pad, pad), (pad, pad), (0, 0)),
mode='constant',
constant_values=0)
padded_white = np.pad(img, ((pad, pad), (pad, pad), (0, 0)),
mode='constant',
constant_values=255)
padded_edge = np.pad(img, ((pad, pad), (pad, pad), (0, 0)), mode='edge')
show(
padded_black,
padded_white,
padded_edge,
titles=["Black padding", "White padding", "Edge padding"],
)
3. Color Channels¶
In an RGB image each pixel has three channel values:
- R (red):
img[:, :, 0] - G (green):
img[:, :, 1] - B (blue):
img[:, :, 2]
Each channel is a 2D array with values 0–255. A pixel's colour is the additive mix of all three channels.
# ── Split and isolate channels ─────────────────────────────────────────────
r = img[:, :, 0]
g = img[:, :, 1]
b = img[:, :, 2]
# Visualise each channel as a grayscale heatmap
fig, axes = plt.subplots(1, 4, figsize=(20, 5))
axes[0].imshow(img)
axes[0].set_title("Original")
axes[0].axis("off")
axes[1].imshow(r, cmap="Reds")
axes[1].set_title("Red channel")
axes[1].axis("off")
axes[2].imshow(g, cmap="Greens")
axes[2].set_title("Green channel")
axes[2].axis("off")
axes[3].imshow(b, cmap="Blues")
axes[3].set_title("Blue channel")
axes[3].axis("off")
plt.tight_layout()
plt.show()
# ── Show only one channel (zero the others) ────────────────────────────────
red_only = np.zeros_like(img)
red_only[:, :, 0] = img[:, :, 0]
green_only = np.zeros_like(img)
green_only[:, :, 1] = img[:, :, 1]
blue_only = np.zeros_like(img)
blue_only[:, :, 2] = img[:, :, 2]
show(red_only,
green_only,
blue_only,
titles=["Red only", "Green only", "Blue only"])
# ── Grayscale conversion ───────────────────────────────────────────────────
# BT.601 weighted luminance (most perceptually accurate)
gray_weighted = np.dot(img[..., :3], [0.2989, 0.5870, 0.1140]).astype(np.uint8)
# Simple mean
gray_mean = img.mean(axis=2).astype(np.uint8)
# Single channel
gray_green = img[:, :, 1]
show(img,
gray_weighted,
gray_mean,
gray_green,
titles=["Original", "Weighted (BT.601)", "Mean", "Green channel only"])
# ── Recombine channels & channel swap ─────────────────────────────────────
merged = np.stack([r, g, b], axis=-1) # back to (H, W, 3)
# RGB → BGR (for OpenCV compatibility)
bgr = img[:, :, ::-1]
# Swap R and B manually
rb_swapped = img.copy()
rb_swapped[:, :, 0] = img[:, :, 2]
rb_swapped[:, :, 2] = img[:, :, 0]
show(img,
merged,
bgr,
rb_swapped,
titles=[
"Original", "Merged (same)", "BGR (R↔B swapped)", "R↔B manual swap"
])
# ── RGBA — circular alpha mask ─────────────────────────────────────────────
alpha = np.full(img.shape[:2], 255, dtype=np.uint8)
rgba = np.dstack([img, alpha])
# Zero alpha outside a circle
h, w = img.shape[:2]
Y, X = np.ogrid[:h, :w]
radius = min(h, w) // 2
outside_circle = (X - w // 2)**2 + (Y - h // 2)**2 > radius**2
rgba[outside_circle, 3] = 0
print(f"RGBA shape: {rgba.shape}")
# Display alpha channel only
show(rgba[:, :, 3], titles=["Alpha channel (white=opaque, black=transparent)"])
RGBA shape: (576, 460, 4)
4. Transforms — Flip, Rotate, Resize¶
Flipping¶
Done entirely with slice notation — no external functions needed.
Rotation¶
np.rot90 handles multiples of 90°. scipy.ndimage.rotate handles arbitrary angles.
Resize method comparison¶
| Method | Quality | Speed | Notes |
|---|---|---|---|
img[::2, ::2] |
Poor (aliasing) | Fastest | Powers of 2 only |
scipy.ndimage.zoom |
Good | Fast | Fractional scales |
PIL.Image.resize |
Excellent | Medium | Best for general use |
skimage.transform.resize |
Excellent | Slower | Best for scientific use |
# ── Flipping ───────────────────────────────────────────────────────────────
flip_h = img[:, ::-1] # horizontal (mirror)
flip_v = img[::-1, :] # vertical (upside-down)
flip_both = img[::-1, ::-1] # both axes
show(img,
flip_h,
flip_v,
flip_both,
titles=["Original", "Flip horizontal", "Flip vertical", "Flip both"])
# ── Rotation (multiples of 90°) ────────────────────────────────────────────
rot90 = np.rot90(img, 1) # 90° CCW
rot180 = np.rot90(img, 2) # 180°
rot270 = np.rot90(img, 3) # 270° CCW (= 90° CW)
print(f"Original : {img.shape}")
print(f"rot90 : {rot90.shape}")
print(f"rot180 : {rot180.shape}")
show(img,
rot90,
rot180,
rot270,
titles=["Original", "90° CCW", "180°", "270° CCW"])
Original : (576, 460, 3) rot90 : (460, 576, 3) rot180 : (576, 460, 3)
# ── Arbitrary angle rotation with scipy ────────────────────────────────────
from scipy.ndimage import rotate
rot45_reshaped = rotate(img, 45) # output resized to fit
rot45_cropped = rotate(img, 45, reshape=False, cval=0) # keep original shape
rot30 = rotate(img, 30, reshape=False, cval=255) # white fill
show(rot45_reshaped,
rot45_cropped,
rot30,
titles=["45° (reshaped)", "45° (cropped, black fill)", "30° (white fill)"])
# ── Resizing ───────────────────────────────────────────────────────────────
# scipy zoom — scale by factor
half_zoom = zoom(img, (0.5, 0.5, 1))
double_zoom = zoom(img, (2.0, 2.0, 1))
# PIL — resize to exact target dimensions
target = (224, 224)
pil_img = Image.fromarray(img)
resized_pil = np.array(pil_img.resize(target, Image.LANCZOS))
print(f"Original : {img.shape}")
print(f"zoom ×0.5 : {half_zoom.shape}")
print(f"zoom ×2.0 : {double_zoom.shape}")
print(f"PIL 224×224 : {resized_pil.shape}")
show(img,
half_zoom,
resized_pil,
titles=[
f"Original {img.shape[:2]}", f"Zoom ×0.5 {half_zoom.shape[:2]}",
f"PIL resize {resized_pil.shape[:2]}"
])
Original : (576, 460, 3) zoom ×0.5 : (288, 230, 3) zoom ×2.0 : (1152, 920, 3) PIL 224×224 : (224, 224, 3)
5. Brightness and Contrast¶
The float conversion rule¶
Always convert uint8 images to float32 before arithmetic. After adjusting, clip to [0, 255] and convert back to uint8.
img_f = img.astype(np.float32) # values still 0–255, no overflow
result = np.clip(img_f, 0, 255).astype(np.uint8)
- Brightness → add/subtract a constant
- Contrast → multiply by a factor
- Gamma → non-linear exponent
# ── Brightness ─────────────────────────────────────────────────────────────
img_f = img.astype(np.float32)
brighter = np.clip(img_f + 80, 0, 255).astype(np.uint8)
darker = np.clip(img_f - 80, 0, 255).astype(np.uint8)
show(darker,
img,
brighter,
titles=["Darker (−80)", "Original", "Brighter (+80)"])
# ── Contrast ───────────────────────────────────────────────────────────────
img_f = img.astype(np.float32)
low_contrast = np.clip(img_f * 0.4, 0, 255).astype(np.uint8)
high_contrast = np.clip(img_f * 1.8, 0, 255).astype(np.uint8)
# Contrast around mean (preserves midtones better)
mean = img_f.mean()
contrast_mid = np.clip((img_f - mean) * 1.8 + mean, 0, 255).astype(np.uint8)
show(low_contrast,
img,
high_contrast,
contrast_mid,
titles=[
"Low contrast (×0.4)", "Original", "High contrast (×1.8)",
"Contrast around mean"
])
# ── Normalisation ──────────────────────────────────────────────────────────
norm_01 = img.astype(np.float32) / 255.0 # [0, 1]
norm_11 = img.astype(np.float32) / 127.5 - 1.0 # [-1, 1]
print(f"[0,1] range: {norm_01.min():.3f} → {norm_01.max():.3f}")
print(f"[-1,1] range: {norm_11.min():.3f} → {norm_11.max():.3f}")
# Per-channel min-max stretch
img_f = img.astype(np.float32)
stretch = img_f.copy()
for c in range(3):
ch = img_f[:, :, c]
stretch[:, :, c] = (ch - ch.min()) / (ch.max() - ch.min() + 1e-8)
stretch_uint8 = (stretch * 255).astype(np.uint8)
show(img, stretch_uint8, titles=["Original", "Per-channel min-max stretch"])
[0,1] range: 0.000 → 1.000 [-1,1] range: -1.000 → 1.000
# ── Gamma correction ───────────────────────────────────────────────────────
img_norm = img.astype(np.float32) / 255.0
dark_gamma = (np.power(img_norm, 2.0) * 255).astype(
np.uint8) # gamma > 1 → darker
bright_gamma = (np.power(img_norm, 0.4) * 255).astype(
np.uint8) # gamma < 1 → brighter
show(dark_gamma,
img,
bright_gamma,
titles=["Gamma 2.0 (darker)", "Original (γ=1)", "Gamma 0.4 (brighter)"])
# ── Histogram equalisation ─────────────────────────────────────────────────
def histogram_equalise(channel):
hist, _ = np.histogram(channel.flatten(), 256, [0, 256])
cdf = hist.cumsum()
cdf_min = cdf[cdf > 0].min()
total = channel.size
lut = np.round((cdf - cdf_min) / (total - cdf_min) * 255).astype(np.uint8)
return lut[channel]
equalised = np.stack([histogram_equalise(img[:, :, c]) for c in range(3)],
axis=-1)
fig, axes = plt.subplots(2, 2, figsize=(12, 8))
axes[0, 0].imshow(img)
axes[0, 0].set_title("Original")
axes[0, 0].axis("off")
axes[0, 1].imshow(equalised)
axes[0, 1].set_title("Equalised")
axes[0, 1].axis("off")
gray = img.mean(axis=2).astype(np.uint8)
axes[1, 0].hist(gray.flatten(), 256, [0, 256], color='gray')
axes[1, 0].set_title("Histogram — original")
gray_eq = equalised.mean(axis=2).astype(np.uint8)
axes[1, 1].hist(gray_eq.flatten(), 256, [0, 256], color='steelblue')
axes[1, 1].set_title("Histogram — equalised")
plt.tight_layout()
plt.show()
/var/folders/k8/h1l_hrqs1gdfmyrtv74dvgb40000gn/T/ipykernel_1788/3917274660.py:16: MatplotlibDeprecationWarning: Passing the range parameter of hist() positionally is deprecated since Matplotlib 3.10; the parameter will become keyword-only in 3.12. axes[1, 0].hist(gray.flatten(), 256, [0,256], color='gray') /var/folders/k8/h1l_hrqs1gdfmyrtv74dvgb40000gn/T/ipykernel_1788/3917274660.py:19: MatplotlibDeprecationWarning: Passing the range parameter of hist() positionally is deprecated since Matplotlib 3.10; the parameter will become keyword-only in 3.12. axes[1, 1].hist(gray_eq.flatten(), 256, [0,256], color='steelblue')
6. Filters and Convolution¶
What is convolution?¶
Convolution slides a small matrix (called a kernel) over the image. At each position, it multiplies the kernel values with the overlapping pixel values and sums the results — producing a new pixel value.
For each pixel (x, y):
output[y, x] = sum(kernel × image_patch_around[y, x])
The kernel encodes the filter's behaviour:
| Kernel | Purpose |
|---|---|
| All equal values, sum=1 | Blur (average) |
| Gaussian weights, sum=1 | Smooth blur |
| Centre=5, neighbours=−1 | Sharpening |
| Sobel X / Y | Edge detection (directional) |
| Laplacian | Edge detection (all directions) |
# ── Helper: apply a 2D kernel to all channels ──────────────────────────────
def apply_filter(img, kernel):
out = np.zeros_like(img, dtype=np.float32)
for c in range(img.shape[2]):
out[:, :, c] = convolve(img[:, :, c].astype(np.float32), kernel)
return np.clip(out, 0, 255).astype(np.uint8)
# ── Box blur ───────────────────────────────────────────────────────────────
k3x3 = np.ones((3, 3)) / 9.0
k7x7 = np.ones((7, 7)) / 49.0
blur3 = apply_filter(img, k3x3)
blur7 = apply_filter(img, k7x7)
# Gaussian blur (scipy — better quality)
gauss2 = gaussian_filter(img, sigma=2)
gauss5 = gaussian_filter(img, sigma=5)
show(img,
blur3,
blur7,
gauss2,
gauss5,
titles=["Original", "Box 3×3", "Box 7×7", "Gaussian σ=2", "Gaussian σ=5"])
# ── Sharpening ─────────────────────────────────────────────────────────────
sharp_kernel = np.array([[0, -1, 0], [-1, 5, -1], [0, -1, 0]])
sharpened = apply_filter(img, sharp_kernel)
# Unsharp masking (more controllable)
blurred = gaussian_filter(img.astype(np.float32), sigma=1)
amount = 2.0
unsharp = np.clip(img.astype(np.float32) + amount * (img - blurred), 0,
255).astype(np.uint8)
show(img,
sharpened,
unsharp,
titles=["Original", "Sharpen kernel", "Unsharp mask (amount=2)"])
# ── Edge detection ─────────────────────────────────────────────────────────
gray = img.mean(axis=2)
# Sobel kernels
sobel_x_k = np.array([[-1, 0, 1], [-2, 0, 2], [-1, 0, 1]])
sobel_y_k = np.array([[-1, -2, -1], [0, 0, 0], [1, 2, 1]])
ex = convolve(gray, sobel_x_k)
ey = convolve(gray, sobel_y_k)
magnitude = np.clip(np.sqrt(ex**2 + ey**2), 0, 255).astype(np.uint8)
# Laplacian
laplacian_k = np.array([[0, -1, 0], [-1, 4, -1], [0, -1, 0]])
laplacian = np.clip(np.abs(convolve(gray, laplacian_k)), 0,
255).astype(np.uint8)
# Scipy built-in Sobel
sx = sobel(gray, axis=1)
sy = sobel(gray, axis=0)
sobel_magnitude = np.clip(np.hypot(sx, sy), 0, 255).astype(np.uint8)
show(img,
magnitude,
laplacian,
sobel_magnitude,
titles=["Original", "Sobel magnitude", "Laplacian", "scipy.sobel"])
# ── Emboss effect ──────────────────────────────────────────────────────────
emboss_kernel = np.array([[-2, -1, 0], [-1, 1, 1], [0, 1, 2]])
embossed = apply_filter(img, emboss_kernel)
embossed_shifted = np.clip(embossed.astype(np.float32) + 128, 0,
255).astype(np.uint8)
show(img,
embossed,
embossed_shifted,
titles=["Original", "Emboss (raw)", "Emboss (shifted +128)"])
7. Masking and Thresholding¶
Boolean masks¶
A boolean mask is an array of True/False values with the same (height, width) as the image. True marks pixels that satisfy a condition.
mask = gray > 128 # shape: (H, W), dtype: bool
Combine conditions with & (AND), | (OR), ~ (NOT) — always wrap each condition in parentheses.
# ── Basic brightness mask ──────────────────────────────────────────────────
gray = img.mean(axis=2)
mask = gray > 128 # bright pixels
binary = (mask * 255).astype(np.uint8) # 0 or 255
result_black = img.copy()
result_black[~mask] = 0 # zero dark pixels
result_red = img.copy()
result_red[~mask] = [255, 0, 0] # red dark pixels
print(f"Bright pixels : {mask.sum():,} ({100*mask.mean():.1f}%)")
print(f"Dark pixels : {(~mask).sum():,} ({100*(~mask).mean():.1f}%)")
show(img,
binary,
result_black,
result_red,
titles=["Original", "Binary mask", "Dark → black", "Dark → red"])
Bright pixels : 99,635 (37.6%) Dark pixels : 165,325 (62.4%)
# ── Multi-condition masks ──────────────────────────────────────────────────
r, g, b = img[:, :, 0].astype(int), img[:, :, 1].astype(int), img[:, :,
2].astype(int)
# Bright pixels in any channel
bright_mask = (r > 200) | (g > 200) | (b > 200)
# Skin-tone approximation
skin_mask = (r > 80) & (g > 50) & (b > 30) & \
(r > g) & (r > b) & ((r - g) > 15)
# Greyscale-ish pixels (channels roughly equal)
grey_mask = (np.abs(r - g) < 25) & (np.abs(g - b) < 25)
result_bright = img.copy()
result_bright[~bright_mask] = [30, 30, 30]
result_skin = img.copy()
result_skin[~skin_mask] = [30, 30, 30]
result_grey = img.copy()
result_grey[~grey_mask] = [30, 30, 30]
show(result_bright,
result_skin,
result_grey,
titles=["Bright only", "Skin tones only", "Greyscale-ish only"])
# ── Morphological operations ───────────────────────────────────────────────
gray = img.mean(axis=2)
mask = gray > 140
eroded = binary_erosion(mask, iterations=5)
dilated = binary_dilation(mask, iterations=5)
opened = binary_opening(mask, iterations=5)
closed = binary_closing(mask, iterations=5)
show((mask * 255).astype(np.uint8), (eroded * 255).astype(np.uint8),
(dilated * 255).astype(np.uint8), (opened * 255).astype(np.uint8),
(closed * 255).astype(np.uint8),
titles=["Original mask", "Eroded", "Dilated", "Opened", "Closed"])
# ── Region labelling ───────────────────────────────────────────────────────
gray = img.mean(axis=2)
mask = gray > 150
labelled, n = label(mask)
print(f"Connected regions found: {n}")
# Keep only regions larger than 500 pixels
large_mask = np.zeros_like(mask)
for i in range(1, n + 1):
region = labelled == i
if region.sum() > 500:
large_mask |= region
result = img.copy()
result[~large_mask] = [30, 30, 30]
show(img, (large_mask * 255).astype(np.uint8),
result,
titles=["Original", "Large regions mask", "Masked result"])
Connected regions found: 223
# ── Image compositing with masks ───────────────────────────────────────────
h, w = img.shape[:2]
# Circular hard mask
Y, X = np.ogrid[:h, :w]
radius = min(h, w) // 2
circle = (X - w // 2)**2 + (Y - h // 2)**2 < radius**2
# Background: blurred version of the image
bg = gaussian_filter(img, sigma=15)
# Hard composite
composite_hard = bg.copy()
composite_hard[circle] = img[circle]
# Soft blend with alpha falloff
dist = np.sqrt((X - w // 2)**2 + (Y - h // 2)**2).astype(np.float32)
alpha = np.clip(1 - (dist - radius * 0.8) / (radius * 0.2), 0, 1)[:, :,
np.newaxis]
blended = (img * alpha + bg * (1 - alpha)).astype(np.uint8)
show(img,
composite_hard,
blended,
titles=["Original", "Hard circular crop", "Soft vignette blend"])
Quick Reference Cheatsheet¶
import numpy as np
from scipy.ndimage import gaussian_filter, sobel, zoom
# Shape & dtype
img.shape # (H, W, C)
img.dtype # uint8
img.astype(np.float32) # convert for math
# Channels
img[:, :, 0] # red channel
np.dot(img[..., :3], [0.2989, 0.5870, 0.1140]) # grayscale
img[:, :, ::-1] # RGB → BGR
np.stack([r, g, b], axis=-1) # merge channels
# Crop & slice
img[y1:y2, x1:x2] # crop rectangle
img[::2, ::2] # half resolution
img[::-1, :] # flip vertical
img[:, ::-1] # flip horizontal
np.rot90(img, k) # rotate 90° × k
# Brightness & contrast
np.clip(img.astype(float) + 50, 0, 255).astype(np.uint8) # brighter
np.clip(img.astype(float) * 1.5, 0, 255).astype(np.uint8) # contrast
img.astype(np.float32) / 255.0 # normalise [0,1]
# Filters
gaussian_filter(img, sigma=2) # Gaussian blur
sobel(img.mean(axis=2), axis=1) # Sobel edge X
zoom(img, (0.5, 0.5, 1)) # resize 50%
# Masking
mask = img.mean(axis=2) > 128 # bool mask
img.copy()[~mask] = 0 # zero dark pixels
(mask * 255).astype(np.uint8) # binary image