Image Fundamentals: Color, Histograms, Noise, Filtering
In this series (30 parts)
- Computer Vision Roadmap: From Basics to Real Projects
- What is Computer Vision? From Pixels to Decisions
- A Short History of Computer Vision: 1545 to Now
- Math for CV Beginners: Vectors, Matrices, Convolutions
- Image Fundamentals: Color, Histograms, Noise, Filtering
- How Images and Video Are Stored: Colour, JPEG, Frames
- OpenCV Setup + First 10 Tasks in Python
- Vision Metrics: Accuracy, Precision, Recall, mAP, IoU
- CV Project Workflow: Dataset, Baseline, Error Iteration
- Intensity Transforms and Frequency-Domain Filtering
- Edge Detection and Thresholding That Actually Work
- Morphology, Contours, and Shape Analysis for Real Images
- Feature Matching (SIFT/ORB) and Image Stitching
- Camera Calibration and Perspective Correction
- Epipolar Geometry, Stereo Vision, and Depth Estimation
- Optical Flow and Motion Tracking in Video
- HOG, HOF and MBH: Descriptors Before Deep Learning
- Your First Image Classifier (PyTorch + Transfer Learning)
- Data Pipelines and Augmentation for Vision Models
- CNN Architectures Explained: From LeNet to ResNet
- YOLO Detection Pipeline: Data to Inference
- Semantic and Instance Segmentation: U-Net to Mask R-CNN
- Vision Transformers (ViT) and When to Use Them
- CV Explainability: Grad-CAM, Failures, Bias Checks
- 3D Vision Basics: SfM, Point Clouds, and Pose Estimation
- Multimodal Vision: CLIP, Embeddings, and Retrieval Systems
- Video Understanding: Flow Networks, Interpolation, Stabilisation
- Real-Time CV Systems: Tracking, Latency, Streaming
- Deploying CV Models: ONNX, TensorRT, Edge, and APIs
- Responsible CV: Privacy, Fairness, Security, Governance
Most vision projects fail on image quality long before they fail on modelling. This post covers the three things that decide image quality: how colour is stored, what a histogram tells you, and how to remove noise without destroying the detail you need.
Prerequisites: Math for CV beginners for convolution and array shapes.
A problem where colour choice decides everything
A packing shed sorts tomatoes on a conveyor. Ripe tomatoes are red and go to the fresh crate. Unripe ones are green and go back for another day. A camera above the belt takes one photo per tomato.
The shed runs from 6am to 8pm. At 6am the light is dim. At noon a skylight floods the belt. At 8pm the overhead lamps are the only source. The tomatoes do not change. The light changes constantly.
This is the classic case where the colour space you pick decides whether the system works.
How colour is stored
An image with colour is three grids stacked. Each grid is one channel One of the parallel 2D grids that make up a colour image. RGB has three: one for red, one for green, one for blue. .
| # | Format | Channels | Range per channel | Where you meet it |
|---|---|---|---|---|
| 1 | Grayscale uint8 | 1 | 0 to 255 | Most classical CV, edges, thresholds |
| 2 | BGR uint8 | 3 | 0 to 255 each | Everything cv2.imread returns |
| 3 | RGB uint8 | 3 | 0 to 255 each | matplotlib, PIL, PyTorch, the web |
| 4 | HSV (OpenCV) | 3 | H 0-179, S 0-255, V 0-255 | Colour rules under changing light |
| 5 | LAB (OpenCV) | 3 | L 0-255, a 0-255, b 0-255 | Perceptual colour distance, CLAHE |
| 6 | float32 normalized | 1 or 3 | typically −3 to +3 | Neural network input |
The image formats you will actually handle, and their real value ranges
OpenCV squeezes hue into 0–179 instead of 0–359 so it fits in a uint8. Every hue value in OpenCV is half the degree value. Forgetting that is the most common HSV bug.
Additive and subtractive: why CMYK exists
One colour model is missing from that table, and it is worth a paragraph because the contrast explains what RGB actually is.
RGB is additive. Each channel says how much light of that colour to emit. Turn all three to maximum and the light adds up to white. Turn them all off and you get black. This is how every screen and every camera sensor works, which is why RGB is the default everywhere in computer vision.
CMYK is subtractive. Cyan, magenta, yellow and black describe how much ink to lay down, and ink does not emit light — it absorbs it. Pile all of them on and almost nothing reflects back, so you approach black. Leave the paper bare and you get white.
| RGB | CMYK | |
|---|---|---|
| Mechanism | Emits light | Absorbs light |
| All channels at maximum | White | Black |
| All channels at zero | Black | White (bare paper) |
| Devices | Screens, cameras, sensors | Printers |
| Appears in computer vision | Constantly | Essentially never |
Two opposite ways of specifying a colour
You will not process CMYK images, because nothing you capture comes from a printer. Knowing it exists matters for one practical reason: it is why a colour that looked right on your screen prints differently, and why design assets sometimes arrive in a colour space your loader silently mangles. If cv2.imread gives you strange colours on a file from a designer, check whether the file is CMYK.
Why RGB breaks and HSV does not
Take one specific red tomato. Here are its pixel values photographed at three times of day.
| # | Time | R | G | B | What changed |
|---|---|---|---|---|---|
| 1 | Noon (bright) | 200 | 40 | 35 | reference |
| 2 | Morning (medium) | 140 | 28 | 24 | all values dropped ~30% |
| 3 | Evening (dim) | 90 | 18 | 16 | all values dropped ~55% |
The same red tomato under three lighting conditions
A rule like “red channel above 150” works at noon, half-works in the morning, and fails completely in the evening. But look closer: every channel dropped by roughly the same proportion. The ratios between R, G, and B barely moved. The colour did not change; only the amount of light did.
HSV is built to expose exactly that. HSV Hue, Saturation, Value. Hue is which colour it is, saturation is how vivid, and value is how bright. Changing the light mostly changes value alone. splits the “which colour” part away from the “how bright” part.
Converting RGB to HSV by hand
Take the noon pixel: , , .
Step 1: scale to 0–1.
Step 2: find max, min, and the spread.
Step 3: Value is just the max.
Step 4: Saturation is the spread relative to the max.
Step 5: Hue depends on which channel was the max. Since red won:
OpenCV halves it: .
Now run the identical steps on the evening pixel (, , ):
Here is the whole point of this post in one table:
| # | Time | R | G | B | H (cv) target | S (cv) | V (cv) |
|---|---|---|---|---|---|---|---|
| 1 | Noon | 200 | 40 | 35 | 1 | 210 | 200 |
| 2 | Morning | 140 | 28 | 24 | 1 | 209 | 140 |
| 3 | Evening | 90 | 18 | 16 | 1 | 210 | 90 |
Same tomato, three lightings. R swings from 200 to 90. Hue does not move at all.
All three channels drop with the light. Hue does not.
Now do the same for an unripe green tomato: , , .
When green is the max, the hue formula shifts by 2:
So ripe sits at hue 1 and unripe sits at hue 53. A single number separates them, and that number does not care what time of day it is.
import cv2
import numpy as np
bgr = cv2.imread("tomato.jpg")
hsv = cv2.cvtColor(bgr, cv2.COLOR_BGR2HSV)
# red wraps around 0 in hue, so it needs two ranges
lower_red_1 = np.array([0, 120, 60])
upper_red_1 = np.array([10, 255, 255])
lower_red_2 = np.array([170, 120, 60])
upper_red_2 = np.array([179, 255, 255])
mask = cv2.inRange(hsv, lower_red_1, upper_red_1) | cv2.inRange(hsv, lower_red_2, upper_red_2)
ripe_fraction = mask.sum() / 255 / mask.size
print(f"ripe pixels: {ripe_fraction:.1%}")
Note the V lower bound of 60 in the mask. Very dark pixels have unreliable hue, because is tiny and dividing by a tiny number amplifies noise. Always floor the value channel when you build a hue mask.
Which colour space when
| Grayscale | BGR / RGB | HSV | LAB | |
|---|---|---|---|---|
| Separates colour from brightness | N/A | No | Yes | Yes |
| Survives lighting change | No | No | Mostly | Well |
| Distance matches human perception | Roughly | Poorly | Poorly | Well |
| Cheap to compute | Yes | Free | Cheap | Cheap |
| Good for | Edges, thresholds, shape | Display, model input | Colour masks, sorting | Colour matching, CLAHE |
| Watch out for | Loses all colour info | Every channel moves with light | Hue is unstable when dark or grey | Channels are not intuitive |
graph TD
Q{"What does your rule<br/>depend on?"} -->|"shape, edges, texture"| G["Grayscale<br/>simpler and faster"]
Q -->|"which colour it is"| H["HSV<br/>threshold on hue"]
Q -->|"how similar two colours look"| L["LAB<br/>Euclidean distance is meaningful"]
Q -->|"feeding a neural network"| R["RGB, normalized<br/>the model learns the rest"]
Histograms: reading an image in one glance
A histogram A count of how many pixels fall into each brightness range. It ignores where the pixels are and only records how many of each value exist. is the fastest diagnostic tool in computer vision. Before you debug an algorithm, look at the histogram.
Compute one by hand
Take this 4×4 grayscale patch:
Use four bins, each 64 values wide:
| Bin | Range | Values that land here | Count |
|---|---|---|---|
| 0 | 0–63 | 10, 12, 14, 11, 9, 13, 12, 10 | 8 |
| 1 | 64–127 | none | 0 |
| 2 | 128–191 | none | 0 |
| 3 | 192–255 | 200, 210, 205, 199, 198, 203, 202, 207 | 8 |
Plotted, those four counts look like this:
Two tall peaks with a completely empty gap between them. That shape is called bimodal A histogram with two clear peaks, meaning the image contains two distinct brightness populations such as an object and its background. , and it is the best news you can get: any cutoff between 64 and 191 splits object from background perfectly. This is exactly the condition Otsu thresholding is designed to find automatically.
What a histogram cannot tell you
Before you trust histograms too much, here is the limitation, and it is a large one.
These three 4×4 images look nothing alike:
Count the values in each. Every one has exactly eight pixels at 10 and eight at 200. Their histograms are identical:
This matters practically. A histogram tells you whether an image is exposed correctly, whether it is bimodal enough to threshold, and whether it has been requantised. It tells you nothing about whether the object is one blob or scattered noise, nothing about texture, and nothing about shape. Two images with the same histogram can require completely different processing.
What real histograms look like
| # | Histogram shape | What it means | What to do |
|---|---|---|---|
| 1 | Bunched at the left | Under-exposed, detail lost in shadows | More light, longer exposure, or CLAHE |
| 2 | Bunched at the right | Over-exposed, highlights clipped | Less light or shorter exposure. Clipping is unrecoverable |
| 3 | Narrow spike in the middle | Low contrast, flat image | Histogram equalization or CLAHE |
| 4 | Two clear peaks | Object and background separate cleanly | Threshold between them, Otsu will find it |
| 5 | One broad smooth hill | Normal photo, no easy threshold | You probably need features or a model |
| 6 | Tall bar exactly at 0 or 255 | Values clipped at the sensor limit | Fix the camera settings, no software fix exists |
How to read a histogram in five seconds
import cv2
import matplotlib.pyplot as plt
gray = cv2.imread("frame.jpg", cv2.IMREAD_GRAYSCALE)
hist = cv2.calcHist([gray], [0], None, [256], [0, 256])
print("clipped at 0:", hist[0][0], " clipped at 255:", hist[255][0])
plt.plot(hist)
plt.xlabel("Pixel value"); plt.ylabel("Count"); plt.show()
Those two clipping counts are worth printing every time. If thousands of pixels sit at exactly 255, the sensor saturated and that information is gone forever. No filter, no model, no amount of training data brings it back. Fix the camera.
Brightness and contrast are two different operations
People use these words loosely, but on a histogram they do completely different things. Brightness slides the histogram. Contrast stretches it.
Take five pixels from a dull, flat image: 100, 105, 110, 115, 120. The mean is 110 and the spread is small.
Brightness: add a constant. Add 40 to every pixel:
The mean moved from 110 to 150. The spread is unchanged, still 7.071, because adding the same number to everything moves every value equally. The image is brighter and just as flat as before.
Contrast: multiply around a pivot. Multiply the distance from the mean by 3:
The mean is still 110, but the spread tripled to 21.21. The values now use 60 levels instead of 20. That is contrast.
Notice the orange histogram is exactly the same shape as the grey one, just relocated. If your image looks washed out, adding brightness will not help. You need the green operation.
OpenCV does both with one call, using :
brighter = cv2.convertScaleAbs(gray, alpha=1.0, beta=40) # slide +40
contrasty = cv2.convertScaleAbs(gray, alpha=3.0, beta=-220) # stretch x3 about 110
print(gray.mean(), gray.std()) # 110.0 7.07
print(contrasty.mean(), contrasty.std()) # 110.0 21.21
The comes from keeping the pivot fixed: to leave 110 unmoved while tripling, you need .
Contrast has a hard limit: multiply too aggressively and values run past 255, where OpenCV clamps them. With , any original pixel above 158 lands beyond 255 and gets flattened into a solid white blob. That is why the automatic version below scales by the actual distribution instead of a number you guessed.
Dynamic range is a third thing
Brightness and contrast are two operations. There is a third quantity people conflate with both, and separating it prevents a whole category of wasted effort.
dynamic range The ratio between the largest and smallest values an image actually contains. It is a property of the data, not something you set.Contrast is about the spread of values around the middle. Dynamic range is about the ratio between the extremes. A thermal sensor reading from 3 to 4000 has a dynamic range of about 1300:1, which no 8-bit display can show linearly — squeeze it into 0–255 and everything below 200 collapses into near-black.
Contrast stretching does not fix that, because the problem is not that values are bunched together. They are spread across three orders of magnitude, and 256 levels cannot represent three orders of magnitude with detail at both ends. The fix is a non-linear curve such as a log transform, which is covered in intensity transforms and frequency-domain filtering.
The related symptom worth recognising here is the opposite problem — too few distinct levels:
If you see that comb pattern in your data, the file has been through a lossy step: heavy JPEG compression, an 8-bit save of 16-bit data, or a screenshot of a screenshot. It is worth finding the original before you train on it, and how images and video are stored covers why it happens.
import numpy as np
levels = np.unique(gray).size
print(f"{levels} distinct levels of 256") # well under 200 is suspicious
Histogram equalization, worked out
Equalization spreads a bunched histogram across the full range. It does this by mapping each level through the cumulative distribution function The running total of pixel counts up to and including each brightness level. Levels with many pixels get spread further apart. of the histogram.
Take a tiny 16-pixel image using 3-bit levels (0 to 7). Suppose the counts are:
| Level | Count | Running total (CDF) |
|---|---|---|
| 3 | 3 | 3 |
| 4 | 5 | 8 |
| 5 | 4 | 12 |
| 6 | 4 | 16 |
Every pixel sits between 3 and 6, so the image looks flat and grey. The mapping formula is:
With , , and :
The values that spanned 3 to 6 now span 0 to 7. Contrast more than doubled, and the levels with the most pixels (level 4, with 5 pixels) got the biggest jump.
That plot shows the key idea. Equalization never changes the counts, only where they sit. Levels with many pixels get pushed far apart, levels with few get squeezed together, and the gap between levels 4 and 5 (5 pixels between them) opened wider than the gap between 5 and 6 (4 pixels).
Here is the same thing seen through the CDF, which is the curve doing the work:
Where the blue CDF is steep, many pixels share that brightness, so equalization spends more of the output range there. Where it is flat, few pixels exist and the output range is not wasted on them. That single rule is the whole algorithm.
equalized = cv2.equalizeHist(gray) # whole image at once
clahe = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8, 8))
local = clahe.apply(gray) # per-tile, gentler
Plain equalizeHist uses one histogram for the whole image, so a bright window in one corner ruins the mapping everywhere else. CLAHE Contrast Limited Adaptive Histogram Equalization. It equalizes small tiles independently and caps how much any one level can be stretched, which avoids amplifying noise. divides the image into tiles and equalizes each one, with a cap on how aggressive it can be. In practice CLAHE is what you reach for.
Noise and the filters that remove it
Noise is unwanted variation in pixel values. Two types cover most of what you meet.
Gaussian noise is small random wobble added to every pixel, caused by sensor electronics. It gets much worse in low light and at high ISO. A pixel whose true value is 50 might read 47, 53, 48, 52 across frames.
Salt-and-pepper noise is a small number of pixels set to extreme values, caused by dead sensor elements, dust, or transmission errors. A pixel whose true value is 50 reads 0 or 255.
They need different treatment, and the reason is arithmetic.
Their histograms make the difference obvious. Take a flat patch whose true value is 50, and corrupt it two ways:
The blue hill has no pixels far from 50, so averaging works well: the errors sit on both sides and cancel out. The red histogram is mostly correct, but the two short bars at 0 and 255 are as far from the truth as it is possible to be. Averaging drags the answer towards them. Sorting and taking the middle value ignores them completely, because a value’s rank does not care how extreme it is.
That is the whole reason there are two different filters.
One noisy window, two filters
Here is a 3×3 window with one salt pixel. The true value in this region is about 50.
Mean. Sum the nine values: .
Median. Sort them: 48, 49, 50, 50, 51, 51, 52, 52, 240. Take the middle one:
One outlier dragged the mean 21 levels away from the truth. The median did not move. That single fact decides which filter you use.
The Gaussian kernel, derived
A plain mean kernel weights all nine pixels equally, which blurs more than necessary. A Gaussian kernel weights the centre more. The weights come from:
For a 3×3 kernel with , the offsets are in each direction:
- Centre, :
- Edges, and :
- Corners, :
Sum of all nine: . Divide each by that sum so the weights add to 1:
The integer version on the right is what you see in textbooks. Dividing by 16 gives 0.0625, 0.125, and 0.25, which is close enough to the exact values and much faster to compute.
The important control is , not the kernel size. Larger means heavier smoothing. OpenCV picks a sensible kernel size for you if you pass 0:
blurred = cv2.GaussianBlur(gray, (0, 0), sigmaX=1.5) # size chosen from sigma
Choosing a filter
| Mean (box) | Gaussian | Median | Bilateral | |
|---|---|---|---|---|
| Removes Gaussian noise | Yes | Yes, better | Somewhat | Yes |
| Removes salt-and-pepper | No, it smears it | No, it smears it | Yes, completely | Partly |
| Keeps edges sharp | No | No | Mostly | Yes |
| Is it a convolution? | Yes | Yes | No | No |
| Speed | Fastest | Fast | Medium | Slow |
| OpenCV call | cv2.blur | cv2.GaussianBlur | cv2.medianBlur | cv2.bilateralFilter |
graph TD
N{"What kind of noise<br/>do you see?"} -->|"grainy everywhere"| G["Gaussian blur<br/>start with sigma = 1"]
N -->|"isolated white or<br/>black dots"| M["Median blur<br/>kernel 3 or 5"]
N -->|"grainy, but edges<br/>must stay crisp"| B["Bilateral filter<br/>slower, worth it"]
N -->|"none visible"| S["Do not filter.<br/>You only lose detail."]
G --> C["Then threshold or<br/>detect edges"]
M --> C
B --> C
The bilateral filter A blur whose weights depend on both how far a neighbour is and how different its value is. Neighbours across an edge get near-zero weight, so edges survive. is the one worth understanding. A Gaussian blur averages a pixel with everything nearby, including pixels on the other side of an edge, which is exactly why edges soften. A bilateral filter adds a second weight based on value difference:
If a neighbour is 150 levels brighter, the second term collapses to near zero and that neighbour contributes nothing. The result is smooth regions with sharp boundaries.
den = cv2.bilateralFilter(img, d=9, sigmaColor=75, sigmaSpace=75)
It is roughly ten times slower than a Gaussian blur. Use it when edge sharpness matters and you can afford the time.
- You can see visible grain or speckle in the raw frames
- Your threshold output has scattered single-pixel blobs
- Edge detection is returning hundreds of tiny fragments
- The camera is running at high ISO or in low light
- The image already looks clean, filtering only removes real detail
- You need to detect thin structures such as wires, cracks, or text strokes
- You are feeding a neural network, which usually learns to handle noise better than a fixed filter
- You have not yet looked at the histogram to check the problem is really noise
Putting it together
Here is the preprocessing chain for the tomato line, in the order it runs.
graph LR A["BGR frame<br/>from camera"] --> B["Median blur 3x3<br/>kill dead pixels"] B --> C["Convert to HSV"] C --> D["inRange on hue<br/>with a V floor"] D --> E["Binary mask"] E --> F["Count mask pixels<br/>compare to threshold"] F --> G["ripe / unripe"] G --> H["Log hue histogram<br/>per hour"] H -->|"histogram drifts"| C
import cv2
import numpy as np
def classify_tomato(bgr):
clean = cv2.medianBlur(bgr, 3)
hsv = cv2.cvtColor(clean, cv2.COLOR_BGR2HSV)
red = cv2.inRange(hsv, (0, 120, 60), (10, 255, 255)) | \
cv2.inRange(hsv, (170, 120, 60), (179, 255, 255))
green = cv2.inRange(hsv, (35, 60, 60), (85, 255, 255))
red_px, green_px = int(red.sum() // 255), int(green.sum() // 255)
if red_px + green_px < 500:
return "no tomato detected"
return "ripe" if red_px > green_px else "unripe"
Notice the last check. Comparing red pixels against green pixels rather than against a fixed count means the rule works whether the tomato is close to the camera or far from it. Fixed absolute counts break the moment anything about the camera position changes.
Practice task
- Photograph one coloured object under three lighting conditions: bright daylight, a dim room, and under a coloured lamp.
- Print the mean BGR and mean HSV of a small central patch in each photo. Confirm that hue moves least.
- Plot the grayscale histogram for each. Identify which one is under-exposed and check whether any pixels are clipped at 0 or 255.
- Add salt-and-pepper noise artificially and remove it with both
GaussianBlurandmedianBlur. Compare the results.
import numpy as np
def add_salt_pepper(gray, amount=0.02):
out = gray.copy()
n = int(amount * gray.size)
ys = np.random.randint(0, gray.shape[0], n)
xs = np.random.randint(0, gray.shape[1], n)
out[ys[:n//2], xs[:n//2]] = 0
out[ys[n//2:], xs[n//2:]] = 255
return out
Compare the two filtered results side by side. The Gaussian version will still show grey smudges where the noise was. The median version will look as if the noise was never there.
Summary
Colour, contrast, and noise are the three properties that decide whether your algorithm has a chance.
RGB mixes colour and brightness into every channel, so any fixed RGB rule dies when the light changes. HSV separates them, and you saw a hand-worked conversion showing hue holding at 1 while the red channel fell from 200 to 90. RGB is additive and makes white; CMYK is subtractive and makes black, which is why it belongs to printers and never appears in vision work. Histograms tell you in one glance whether an image is under-exposed, over-exposed, clipped, or cleanly bimodal — but they discard every bit of spatial information, so three completely different images can share one histogram. CLAHE fixes low contrast without blowing out bright regions. Noise type decides filter choice: median for isolated outliers, Gaussian for grain, bilateral when edges must stay sharp.
- OpenCV hue runs 0 to 179, which is half the usual degree value. Red wraps around the seam and needs two ranges.
- Always put a floor on the V channel in a hue mask, because hue is meaningless in very dark pixels.
- RGB is additive and makes white at maximum; CMYK is subtractive and makes black. CMYK belongs to print, not to vision.
- Print the histogram counts at 0 and 255 before anything else. Clipped pixels are permanently lost data.
- A bimodal histogram means thresholding will work. One broad hill means it will not.
- A histogram counts values and discards where they are. Three unrelated images can have identical histograms, so never match images by histogram alone.
- Brightness slides the histogram, contrast widens it, and dynamic range is a property of the data that neither one fixes.
- A comb histogram with gaps means the file has already been requantised somewhere upstream.
- CLAHE beats plain equalization on almost every real image because it works per tile with a clip limit.
- Mean and Gaussian filters are dragged by outliers. Median ignores them entirely.
- Bilateral filtering keeps edges because its weights depend on value difference as well as distance.
- Filter as little as possible. Every blur trades away detail you may need later.
What comes next
How images and video are stored covers what happened to your pixels before your code ever saw them — sampling, bit depth, JPEG, and video frame types. It is the natural companion to this post, because most of the odd histograms you will meet in real data are explained there.
OpenCV setup and your first 10 tasks gets a working environment on your machine and walks through ten exercises that put all of this into practice. After that, edge detection and thresholding uses the histograms from this post to pick thresholds automatically instead of by hand.