cs 180 / 280a · proj. 2

fun with filters and frequencies

intro

lots of fun with {kernels, edge detection, frequency-domain image processing, and multiresolution blending}.

1.1 convolutions from scratch

a 2d convolution slides a flipped kernel over the image and takes a dot product at each pixel. i chose to implement “same” zero-padding, so the output shape is the same as the input shape. we pad by k//2 and (k−1)//2 on opposite sides so both even and odd-sized kernels can be applied accurately at the image borders.

i made two versions of convolution from scratch using numpy. the four-loop version explicitly multiplies each kernel entry against the corresponding image pixel. the two-loop version still walks each pixel, but np.sum does the window multiplication. compared with scipy.signal.convolve2d, both of my versions are obviously slower, though they use the same convolution convention by flipping the kernel with np.flip. on the cameraman (542×540) with a 9×9 box, the four-loop run took 5.9s, the two-loop 0.49s, and scipy 0.02s. the three outputs match to ~1e-15. the scipy version is faster because it is compiled (and can use fft); my padding is explicit zero-fill, which can be reproduced in scipy with mode='same' and fillvalue=0.

Convolution: (I * K)(x, y) = ΣᵢΣⱼ I(x − i, y − j)K(i, j)

Same padding output size: Hout = Hin, Wout = Win

Python
def zeroPadding(image, kernelRows, kernelCols):
    top, bottom = kernelRows // 2, (kernelRows - 1) // 2
    left, right = kernelCols // 2, (kernelCols - 1) // 2
    cpy = np.zeros((len(image) + top + bottom, len(image[0]) + left + right))
    cpy[top:top + len(image), left:left + len(image[0])] = image
    return cpy
Python
def fullBruteForceConvolution(image, kernel):
    rows, cols = len(image), len(image[0])
    kernelRows, kernelCols = len(kernel), len(kernel[0])
    imagePadded = zeroPadding(image, kernelRows, kernelCols)
    output = np.zeros((rows, cols))
    convKernel = np.flip(kernel)
    for i in range(rows):
        for j in range(cols):
            runningTotal = 0
            for u in range(kernelRows):
                for v in range(kernelCols):
                    runningTotal += imagePadded[i + u][j + v] * convKernel[u][v]
            output[i, j] = runningTotal
    return output
Python
def modifiedBruteForceConvolution(image, kernel):
    rows, cols = len(image), len(image[0])
    kernelRows, kernelCols = len(kernel), len(kernel[0])
    imagePadded = zeroPadding(image, kernelRows, kernelCols)
    output = np.zeros((rows, cols))
    convKernel = np.flip(kernel)
    for i in range(rows):
        for j in range(cols):
            output[i, j] = np.sum(
                imagePadded[i:i + kernelRows, j:j + kernelCols] * convKernel
            )
    return output

here are my hand-rolled convolution functions in action against a selfie of me in Qahwa Time (yemeni coffee open till midnight every day, i recommend!). i applied a 9×9 box filter, and also convolved the selfie with the x- and y-derivative filters.

grayscale selfie
grayscale selfie
selfie after 9 by 9 box filter
9×9 box filter
selfie x derivative
convolved with x-derivative filter Dₓ = [1, 0, −1]
selfie y derivative
convolved with y-derivative filter Dᵧ = [1, 0, −1]ᵀ

1.2 finite difference operator

on the cameraman (and my selfie), dx = [1, 0, −1] picks up vertical edges and dy picks up horizontal ones. the gradient magnitude is √(dx² + dy²). to turn the gradient magnitude into a binary edge image, i tested thresholds in {0.05, 0.1, 0.2, 0.23, 0.4, 0.5} and chose 0.23. lower thresholds kept too much grass texture as noise, while higher thresholds started removing real edges in the tripod and coat. 0.23 felt like the best qualitative tradeoff as it kept the main contours while suppressing most of the field texture.

Dₓ = [1 0 −1], Dᵧ = [1 0 −1]ᵀ

Gradient magnitude: ||∇I|| = √((I * Dₓ)² + (I * Dᵧ)²)

cameraman original
cameraman
cameraman partial derivatives and gradient magnitude
∂x, ∂y, and Gradient Magnitude
cameraman edge thresholds with chosen 0.23 on the right
Threshold (α) sweep · chosen 0.23 on the right

1.3 derivative of gaussian (DoG) filter

the finite difference results are pretty noisy because derivative filters amplify high frequencies in the image. to reduce that noise, i blurred the image before taking the derivative. i made an 11×11 gaussian filter with σ = 2 using cv2.getGaussianKernel, then turned the 1d gaussian into a 2d filter with an outer product. after that blur, i reused the same derivative filters, gradient magnitude calculation, and thresholding process as before. because the blurred image has smaller gradient magnitudes, the useful thresholds are also smaller, around 0.02 to 0.08. i chose t = 0.04 because it keeps the coat and tripod edges while removing most of the grass texture.

i also combined the gaussian blur and derivative step into a single derivative-of-gaussian filter by convolving the gaussian with dx and dy. this means one filter can smooth the image and take the derivative at the same time. the blur-then-differentiate result and the DoG result look extremely similar at α = 0.04, which makes sense because convolution is associative. the small leftover differences come from mode='same' padding at the image boundary, not from a different edge detector.

2D Gaussian: G(x, y) = (1 / 2πσ²)e^{−(x² + y²) / 2σ²}

DoG filters: DoGₓ = G * Dₓ, DoGᵧ = G * Dᵧ

(I * G) * Dₓ = I * (G * Dₓ)

gaussian and derivative of gaussian filters
Gaussian, DoG ∂x, DoG ∂y
blurred and DoG gradient comparison
Blur-then-differentiate vs DoG: ∂x, ∂y, magnitude
blurred edge thresholds
Blurred Threshold (α) sweep
edge comparison at threshold 0.04
Blur then differentiate vs DoG, α = 0.04

2.1 image sharpening

a gaussian blur acts like a low-pass filter because it keeps the smooth, slowly changing parts of the image and removes a lot of the fine detail. if i subtract the blurred image from the original, what is left is mostly the high-frequency detail, like edges and texture. unsharp masking sharpens the image by adding some of those high frequencies back into the original: I + α(I − G∗I). this is equivalent to applying one combined convolution filter, (1+α)δ − αG. for the taj example, i used α = 1.1 for the two-step version and α = 1.2 for the single-kernel version.

this works well on chasu and the red panda because both images start out a little soft, so boosting the high frequencies makes the details look clearer. the kiss example is different because the original image starts sharp. after i blur it and then try to sharpen it again, the edges come back somewhat, but the result still does not match the original. the gaussian blur already removed some real detail, and the unsharp mask can emphasize existing edges but cannot invent detail that was already lost.

i recently came back from japan and discovered how cute red pandas are in real life. i also had chasu, or braised pork belly, ramen for the first time in tokyo, and it was phenomenal, so i chose those as two of my images. the kiss art piece is also a lego decoration i have in my room, so i thought it would be a fun image to use for the blur-and-resharpening test.

High frequencies: H = I − (G * I)

Unsharp mask: Isharp = I + αH = I + α(I − G * I)

Single filter: Isharp = I * ((1 + α)δ − αG)

taj mahal sharpening steps
taj · original, blur, high frequencies, two-step sharp, unsharp kernel
taj sharpening amounts
taj · α = 0.5, 1.2, 2.5
chasu sharpening
chasu · original, blur, high frequencies, sharpened
red panda sharpening
red panda · original, blur, high frequencies, sharpened
kiss blur then sharpen evaluation
evaluation · kiss original, blurred, then sharpened

2.2 hybrid images

a hybrid image combines the low frequencies from one picture with the high frequencies from another. from far away, the low-frequency image dominates because the viewer mostly sees broad shapes and smooth color changes. up close, the high-frequency image becomes more visible because the edges and fine details are easier to see. i created the low-pass image by applying a gaussian blur, and i created the high-pass image by subtracting a gaussian blur from the original image. before combining them, i click-aligned each pair so important features, like the eyes or the can and glass, landed in the same place.

a hybrid of my mom and dad is my main process example. my dad is the low-pass source, so he is the image that appears from far away, while my mom is the high-pass source, so she is the image that appears more clearly up close. for this pair, i used a 41×41 gaussian with σ = 16. the fft visualizations show the frequency split: the low-pass image has most of its energy near the center, the high-pass image removes much of that center energy, and the final hybrid contains both kinds of information. for the other examples, derek + nutmeg uses σ = 8, while ghost + raspberry uses σ = 14 so the raspberry shape still reads from far away and the ghost logo stays crisp up close.

Low-pass source: L = G * Ilow

High-pass source: H = Ihigh − (G * Ihigh)

Hybrid image: Ihybrid = L + H

aligned papa
papa, aligned · low-pass source
aligned mama
mama, aligned · high-pass source
gaussian low-pass papa
low-pass papa
high-pass mama
high-pass mama
mama papa hybrid
hybrid · far = papa, close = mama · ksize=41, σ=16
mama papa fourier transforms
log fft · sources, low-pass, high-pass, hybrid
derek nutmeg aligned sources and hybrid
derek + nutmeg · aligned sources and hybrid · ksize=25, σ=8
ghost raspberry aligned sources and hybrid
ghost + raspberry · aligned sources and hybrid · ksize=35, σ=14

2.3 gaussian and laplacian stacks

a stack is similar to a pyramid, except it never downsamples the image. every gaussian level stays the same size as the original image; each level is just a blurrier version of the previous one. i built the laplacian stack by subtracting each gaussian level from the next blurrier gaussian level. the final, coarsest gaussian is kept as the residual, so adding all of the laplacian bands and the residual reconstructs the original image.

for the figure 3.42 recreation, i used a vertical step mask and blurred that mask through the same gaussian stack. each row shows a different laplacian level: level 0 captures the highest-frequency detail, level 4 captures medium-scale structure, and level 8 captures the lowest-frequency color and lighting. in each row, the left image is the apple contribution multiplied by the mask, the middle image is the orange contribution multiplied by 1−mask, and the right image is their sum. the final row shows the original apple and orange sources, followed by the collapsed oraple result.

Gaussian stack: G₀ = I, Gᵢ = G * Gᵢ₋₁

Laplacian stack: Lᵢ = Gᵢ − Gᵢ₊₁, Lₙ = Gₙ

Reconstruction: I = Σᵢ Lᵢ

recreated szeliski figure 3.42 oraple
figure 3.42 · laplacian stack blending of apple and orange

2.4 multiresolution blending

for multiresolution blending, i blend the two laplacian stacks one level at a time. at each level, i use the matching gaussian-mask level to interpolate between the two images: α·a + (1−α)·b. then i sum the blended laplacian bands back together to get the final image. using a gaussian stack for the mask is important because it lets high-frequency details keep a relatively sharp boundary while lower-frequency colors and lighting transition more smoothly.

the apple / orange blend is the vertical-seam example from figure 3.42. the junior year / sophomore year blend uses the same kind of vertical step mask on two photos of me from sophomore to junior year, after click-aligning the eyes. the lombard / lava example uses an irregular hand-drawn mask instead of a straight seam. i used the famous lombard street in san francisco as the base image, and in the mask, white selects the road area, so the lava fills the street while the buildings stay mostly from the original lombard image.

Blended level: Lᶜᵢ = MᵢLᴬᵢ + (1 − Mᵢ)Lᴮᵢ

Final blend: Iblend = Σᵢ Lᶜᵢ

apple orange laplacian blend
oraple · vertical step mask
junior year sophomore year blend strip
junior year / sophomore year · vertical seam after aligning eyes
lombard lava blend strip
lombard, lava, mask, blend
lombard lava laplacian blending process
process · laplacian levels 0, 4, 8 of lava×mask, street×(1−mask), sum; then sources and result
final lombard lava multiresolution blend
look how cool!!

reflection

one of the most important things i learned was how crucial gaussian blurring is for smooth blending. at first, i made the mistake of using a hard, unblurred mask, and the difference after applying a gaussian stack to the mask was night and day. the transition became much more natural because each frequency band blended at the right scale.

i also learned how important alignment and image compatibility are, especially for hybrid images. even if the math is correct and the parameters like ksize and sigma are well chosen, the result will still look bad if the images are not aligned or do not share compatible shapes and features. i had to try several image pairs before finding hybrids that both looked interesting and actually worked perceptually.