Search…

Intensity Transforms and Frequency-Domain Filtering

In this series (30 parts)
  1. Computer Vision Roadmap: From Basics to Real Projects
  2. What is Computer Vision? From Pixels to Decisions
  3. A Short History of Computer Vision: 1545 to Now
  4. Math for CV Beginners: Vectors, Matrices, Convolutions
  5. Image Fundamentals: Color, Histograms, Noise, Filtering
  6. How Images and Video Are Stored: Colour, JPEG, Frames
  7. OpenCV Setup + First 10 Tasks in Python
  8. Vision Metrics: Accuracy, Precision, Recall, mAP, IoU
  9. CV Project Workflow: Dataset, Baseline, Error Iteration
  10. Intensity Transforms and Frequency-Domain Filtering
  11. Edge Detection and Thresholding That Actually Work
  12. Morphology, Contours, and Shape Analysis for Real Images
  13. Feature Matching (SIFT/ORB) and Image Stitching
  14. Camera Calibration and Perspective Correction
  15. Epipolar Geometry, Stereo Vision, and Depth Estimation
  16. Optical Flow and Motion Tracking in Video
  17. HOG, HOF and MBH: Descriptors Before Deep Learning
  18. Your First Image Classifier (PyTorch + Transfer Learning)
  19. Data Pipelines and Augmentation for Vision Models
  20. CNN Architectures Explained: From LeNet to ResNet
  21. YOLO Detection Pipeline: Data to Inference
  22. Semantic and Instance Segmentation: U-Net to Mask R-CNN
  23. Vision Transformers (ViT) and When to Use Them
  24. CV Explainability: Grad-CAM, Failures, Bias Checks
  25. 3D Vision Basics: SfM, Point Clouds, and Pose Estimation
  26. Multimodal Vision: CLIP, Embeddings, and Retrieval Systems
  27. Video Understanding: Flow Networks, Interpolation, Stabilisation
  28. Real-Time CV Systems: Tracking, Latency, Streaming
  29. Deploying CV Models: ONNX, TensorRT, Edge, and APIs
  30. Responsible CV: Privacy, Fairness, Security, Governance

A thermal camera on a furnace line produces images where the hot zone reads 4000 and the cool background reads 3. Displayed normally, the background is a solid black wall and the entire scene looks like one bright blob on nothing. Everything is technically there. None of it is visible.

This post is about the operations that fix that — and about a second way of looking at an image that makes blurring, sharpening and denoising all the same operation with a different setting.

Prerequisites: image fundamentals for histograms and convolution, and math for CV for the idea of a kernel.

Where this came from

Two problems created this field, and both are worth knowing because both still describe why you would use it.

In 1964 the Jet Propulsion Laboratory received the first close-up photographs of the Moon from Ranger 7. The images arrived corrupted by transmission noise and geometric distortion from the vidicon camera. Computers were used to correct them, and that work is generally taken as the start of digital image processing.

In 1979 Godfrey Hounsfield and Allan Cormack shared the Nobel Prize in Physiology or Medicine for computed tomography. A CT scan is not photographed — it is computed from X-ray absorption measurements. Without image processing there is no image at all.

The pattern is the same in both: the sensor gives you numbers, not a picture. Turning those numbers into something a human or an algorithm can use is the job.

Two kinds of operation

A point operation is a lookup table. There are only 256 possible input values in an 8-bit image, so you can precompute all 256 outputs once and apply them with a single array index. That makes point operations essentially free, no matter how complicated the maths looks.

Neighbourhood operations cost real time because each output pixel requires reading a whole window.

Point operations, worked

Let us use a small strip of real intensities to make each transform concrete:

Input r (1×6)
3
12
47
120
200
255

The negative

The simplest one. For an image with L levels:

g = (L - 1) - f
Input (1×6)
3
12
47
120
200
255
255 − f →
Negative (1×6)
252
243
208
135
55
0

This looks like a novelty until you work with medical images. Radiologists routinely invert X-rays and mammograms, because small bright details on a dark background are harder for the human eye to pick out than small dark details on a light background. Nothing is added — the same information is simply easier to see.

The log transform

Here is the fix for the furnace problem.

s = c · log(1 + |r|)

The 1 + exists so that r = 0 maps to s = 0 rather than to negative infinity. The constant c scales the result back into display range. To find it, force the maximum input to map to the maximum output:

c = (L - 1) / log(1 + R_max)

With L = 256 and a sensor whose peak reading is R_max = 4000:

c = 255 / log(1 + 4000)
  = 255 / log(4001)
  = 255 / 8.294
  = 30.75

Now apply it to a range of readings:

Sensor reading r log(1+r) s = 30.75 · log(1+r) Linear scaling would give
3 1.386 43 0
12 2.565 79 1
47 3.871 119 3
200 5.303 163 13
1000 6.909 212 64
4000 8.294 255 255

The log transform against naive linear scaling. Linear scaling crushes everything below 200 into the bottom 5% of the range; the log transform spreads it across most of the scale.

Look at the last column. Linear scaling maps a reading of 47 to 3 — indistinguishable from black. The log transform maps it to 119, comfortably mid-grey.

dynamic range The ratio between the largest and smallest values an image contains. A scene with values from 3 to 4000 has a dynamic range of about 1300:1, far more than the 256 levels an 8-bit display can show.

Dynamic range and contrast are different things, and confusing them causes real mistakes. Dynamic range is the span of values present. Contrast is how much difference there is between neighbouring regions. An image can have huge dynamic range and terrible local contrast — the furnace image is exactly that. The log transform does not add information; it reallocates display range from where the data is sparse to where it is dense.

Gamma correction

s = c · r^γ

Working on normalised values in [0, 1] with c = 1, the exponent decides everything:

Take r = 0.25:

γ = 0.5:  s = 0.25^0.5 = 0.500   → doubled
γ = 1.0:  s = 0.25^1.0 = 0.250   → unchanged
γ = 2.0:  s = 0.25^2.0 = 0.0625  → quartered

The property that makes gamma useful is that it never clips. Both endpoints stay put, so no matter how aggressive the exponent, you never lose highlights to white or shadows to black. Brightness offsets clip; gamma does not.

Brightness (+b)Contrast (×a)Gamma (r^γ)Log
Formulas = r + bs = a·rs = r^γs = c·log(1+r)
Clips?Yes, at both endsYes, at the topNoNo
Affects mid-tonesSame shift everywhereProportionalNon-linearlyStrongly
Best forUniformly dark/bright imageFlat, washed-out imageDisplay gamma, tuning perceived brightnessData spanning orders of magnitude
Four ways to change intensity, and what each one costs

Gray-level slicing

Sometimes you do not want a smooth curve at all — you want one band of intensities and nothing else.

Suppose a thermal inspection cares only about surfaces between 180 and 220 in the mapped image. Two variants:

Binary slicing — the band becomes white, everything else black:

Input (1×6)
43
119
163
190
212
240
Binary slice [180,220] (1×6)
0
0
0
255
255
0

Preserving slicing — the band is brightened, everything else keeps its original value:

Input (1×6)
43
119
163
190
212
240
Preserving slice (1×6)
43
119
163
255
255
240

Binary slicing gives you a clean mask for measuring area. Preserving slicing keeps context so a human can see where the highlighted region sits. Choose by whether the output is for a machine or a person.

import numpy as np

def slice_levels(img, low, high, preserve=False):
    mask = (img >= low) & (img <= high)
    out = img.copy() if preserve else np.zeros_like(img)
    out[mask] = 255
    return out

The frequency view

Now the second half, and the more powerful idea.

spatial frequency How rapidly intensity changes across space. A smooth gradient is low frequency. A fine texture, a sharp edge, or pixel-level noise is high frequency.

Any image can be written as a sum of 2D sine waves of different frequencies, orientations and amplitudes. The Fourier transform tells you the amplitude of each. Nothing is lost — you can transform back exactly.

Follow the two paths in that diagram. You can go the long way round — transform, multiply, transform back — or you can convolve directly in the spatial domain. They give the same answer. That is the convolution theorem:

g = f * h    ⟺    G = F · H

Convolution in space is multiplication in frequency.

What this actually tells you

This equivalence explains things that otherwise have to be memorised separately.

Spatial operation What it does in frequency So it is a…
Mean / Gaussian blur Multiplies high frequencies by near-zero Low-pass filter
Sharpen kernel Boosts high frequencies High-pass filter (plus original)
Sobel / gradient Keeps high frequencies in one direction Directional high-pass filter
Downsampling Discards frequencies above the new limit Low-pass, then resample

Common spatial operations, restated as frequency operations

Take the two kernels you already know:

Mean blur (3×3)
1/9
1/9
1/9
1/9
1/9
1/9
1/9
1/9
1/9
Sharpen (3×3)
-1/9
-1/9
-1/9
-1/9
8/9
-1/9
-1/9
-1/9
-1/9

Sum the mean kernel’s coefficients: 9 × (1/9) = 1. A kernel whose coefficients sum to 1 passes a constant region unchanged — constant means frequency zero, so it passes DC. And it averages away rapid variation, so it kills high frequency. That is a low-pass filter, derived from nothing but the numbers in the box.

Now sum the sharpen kernel: 8/9 − 8×(1/9) = 0. A kernel summing to zero outputs zero on any flat region. It responds only where things change. That is a high-pass filter.

You can read a kernel’s frequency behaviour straight off its coefficient sum. Sum to 1, low-pass. Sum to 0, high-pass.

Filtering in the frequency domain directly

An ideal low-pass filter keeps everything within a radius D₀ of the centre and deletes the rest:

H(u,v) = 1   if D(u,v) ≤ D₀
         0   if D(u,v) >  D₀

where D(u,v) is the distance from the frequency-domain origin.

This is intuitive and it produces a visible artifact. A perfectly sharp cutoff in frequency corresponds to a spatial kernel that oscillates — it rings. The result has faint ripples radiating from every strong edge, like ripples in water.

ringing Oscillating light and dark bands appearing near edges after filtering. It is caused by a filter with a sharp transition in the frequency domain, and it is a mathematical consequence, not a bug.

The Butterworth filter softens the transition:

H(u,v) = 1 / (1 + [D(u,v)/D₀]^(2n))

The order n controls sharpness. Work through the values at the cutoff and around it, with D₀ = 30:

D(u,v) Ideal Butterworth n=1 Butterworth n=2 Butterworth n=4
10 1.00 0.900 0.988 1.000
20 1.00 0.692 0.836 0.962
30 1.00 0.500 0.500 0.500
40 0.00 0.360 0.240 0.091
60 0.00 0.200 0.059 0.004

Filter response at various distances from the origin, D₀ = 30. Every Butterworth order passes exactly 0.5 at the cutoff — that is what defines D₀.

Higher order means a sharper transition, better frequency separation, and more ringing. Lower order means gentler separation and clean edges. There is no setting that gives both, and that is a property of the mathematics rather than a limitation of the implementation.

The honest summary: in day-to-day work you will filter spatially. The frequency view earns its place by explaining why the spatial filters behave as they do, and by handling periodic noise, which nothing spatial handles well.

Doing it in code

import cv2
import numpy as np

# --- Point operations as lookup tables (256 entries, applied instantly) ---

def log_lut(r_max=255.0):
    c = 255.0 / np.log(1.0 + r_max)
    table = c * np.log(1.0 + np.arange(256, dtype=np.float64))
    return np.clip(table, 0, 255).astype(np.uint8)

def gamma_lut(gamma):
    table = ((np.arange(256) / 255.0) ** gamma) * 255.0
    return np.clip(table, 0, 255).astype(np.uint8)

gray = cv2.imread("input.png", cv2.IMREAD_GRAYSCALE)
logged    = cv2.LUT(gray, log_lut())
brightened = cv2.LUT(gray, gamma_lut(0.5))
negative   = 255 - gray            # no LUT needed

cv2.LUT is the right tool here. It replaces a per-pixel pow call with a single array index, and on a 4K image that is the difference between milliseconds and hundreds of milliseconds.

For real high-dynamic-range data, do not force it into uint8 first:

raw = load_thermal()                        # float32, range 3 .. 4000
c = 255.0 / np.log(1.0 + raw.max())
disp = np.clip(c * np.log(1.0 + raw), 0, 255).astype(np.uint8)

Frequency-domain filtering:

def butterworth_lowpass(shape, d0, n=2):
    h, w = shape
    v = np.arange(h)[:, None] - h / 2
    u = np.arange(w)[None, :] - w / 2
    d = np.sqrt(u**2 + v**2)
    return 1.0 / (1.0 + (d / d0) ** (2 * n))

f  = np.fft.fftshift(np.fft.fft2(gray.astype(np.float32)))
H  = butterworth_lowpass(gray.shape, d0=30, n=2)
out = np.real(np.fft.ifft2(np.fft.ifftshift(f * H)))
out = np.clip(out, 0, 255).astype(np.uint8)

The two fftshift calls are the step people get wrong. fft2 puts the zero frequency at the array corner; fftshift moves it to the centre so a centred radial mask works. You must undo it with ifftshift before the inverse transform, or the output comes back scrambled.

To see a spectrum:

spectrum = 20 * np.log(np.abs(f) + 1)       # log again — spectra have huge dynamic range
spectrum = cv2.normalize(spectrum, None, 0, 255, cv2.NORM_MINMAX).astype(np.uint8)

Note that the log transform reappears here for exactly the reason from the top of this post: the DC term dwarfs everything else, and without a log you see one bright dot and nothing more.

Practice task

About forty-five minutes.

  1. Find or make a high-dynamic-range image — a photo of a lit window from inside a dark room works well. Display it with plain linear scaling, then with the log transform. Compute c yourself from the actual maximum.
  2. Apply gamma at 0.4, 0.7, 1.5 and 2.5. Plot the histogram after each. Notice that nothing ever piles up at 0 or 255.
  3. Compare a +60 brightness shift against γ = 0.6. Both brighten. Look at the top of the histogram in each case and identify which one clipped.
  4. Take a photo and add periodic noise: img + 30*np.sin(np.arange(w)[None,:] * 0.4). Try to remove it with a Gaussian blur. Then look at the FFT magnitude, find the two bright points off-centre, zero them, and invert.
  5. Run an ideal low-pass at D₀ = 20, then Butterworth n = 2 at the same cutoff. Zoom into a strong edge in each and find the ringing.

Step 4 is the one that makes the frequency domain click. The noise that no spatial filter could touch becomes two pixels you delete by hand.

Summary

Point operations change each pixel using only its own value, which makes them lookup tables and therefore free. The negative aids human reading, the log transform compresses dynamic range so faint detail survives the trip to an 8-bit display, gamma adjusts perceived brightness without ever clipping, and gray-level slicing isolates a known band when physics has already given you the threshold.

The frequency view says any image is a sum of sine waves, with smooth regions at low frequency and edges and noise at high frequency. The convolution theorem — g = f * hG = F · H — means every spatial filter you already use is a frequency filter in disguise, and you can read which kind off the kernel’s coefficient sum.

Filtering directly in frequency is worth it for periodic interference and for very large kernels. Its cost is ringing whenever the cutoff is sharp, which the Butterworth family trades against frequency selectivity. You cannot have both.

What comes next

Edge detection and thresholding takes the high-pass idea and turns it into a working edge pipeline, including why Canny blurs before it differentiates — which is now a statement about frequency rather than an arbitrary step.

If you want to see the frequency domain used for something other than enhancement, how images and video are stored shows JPEG using the closely related DCT to compress rather than filter, keeping the low frequencies and rounding the high ones to zero.

Test your understanding
A microscope produces 16-bit images where the background is around 40 and the fluorescent markers reach 12000. Converted to 8-bit for display, the markers are visible but all the cell structure is black. Which transform recovers the structure?
Test your understanding
You apply an ideal low-pass filter with a sharp cutoff to remove sensor noise. The noise is gone, but every strong edge now has faint light and dark bands beside it. What is happening and what is the fix?

Frequently asked questions

When would I use a log transform instead of histogram equalisation?
Use log when the data spans orders of magnitude — thermal sensors, microscopy, astronomy, raw HDR captures. Use equalisation when values are bunched into a narrow band and you want to spread them out. The distinction matters because equalisation allocates display levels according to pixel count, so if 95% of pixels are dark background it gives the background most of the range and leaves your subject flat. Log allocates by value instead. On genuinely high-dynamic-range data, applying log first and equalisation afterwards is often better than either alone.
Do I actually need the Fourier transform in practice?
You will rarely call an FFT in production computer vision code, and you will use the frequency idea constantly. It explains why Canny blurs before differentiating, why downsampling without an anti-alias filter produces moiré, why a kernel summing to zero detects edges, and what a convolutional layer's first filters are doing. The one place you genuinely need the transform itself is periodic interference — scan lines, mains hum, screen-door sensor patterns — which appears as a few isolated bright points you can delete, and which no spatial filter removes cleanly.
How do I choose the cutoff D₀ and order n for a Butterworth filter?
Start by looking at the log-magnitude spectrum of your image and finding where the structure you want ends and the noise begins — often visible as a change in texture. Set D₀ there. For the order, start at n=2. Go lower if you see ringing near edges; go higher only if genuine signal and noise are at nearby frequencies and you need them separated more crisply, accepting ringing as the price. In practice n between 1 and 4 covers almost everything, and n above about 6 approaches an ideal filter with all its artifacts.
Why does the sharpen kernel have coefficients that sum to zero?
Because it is a high-pass filter, and a high-pass filter must output nothing on a region with no variation. If the coefficients sum to zero, then applying the kernel to a constant patch gives constant × 0 = 0. Any non-zero sum leaves a scaled copy of the original brightness underneath the detected edges, which then contaminates whatever threshold you apply downstream. The same reasoning in reverse explains blur kernels: they sum to 1 so that a constant region passes through unchanged instead of being brightened or darkened.
Should intensity transforms be applied before or after resizing?
Apply point operations such as gamma and log before resizing when you can. Resizing averages neighbouring pixels, and averaging then applying a non-linear curve is not the same as applying the curve then averaging — the non-linearity means order changes the result. The difference is small on smooth regions and noticeable on high-contrast edges. More importantly, keep the order consistent between training and inference. A pipeline that gammas-then-resizes at training time and resizes-then-gammas in production has a genuine domain gap that is very hard to spot.
Start typing to search across all content
navigate Enter open Esc close