Search…

Image Fundamentals: Color, Histograms, Noise, 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

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: R=200R = 200, G=40G = 40, B=35B = 35.

Step 1: scale to 0–1.

r=200255=0.784,g=40255=0.157,b=35255=0.137r = \frac{200}{255} = 0.784, \quad g = \frac{40}{255} = 0.157, \quad b = \frac{35}{255} = 0.137

Step 2: find max, min, and the spread.

max=0.784  (from r),min=0.137,Δ=0.7840.137=0.647\text{max} = 0.784 \;(\text{from } r), \quad \text{min} = 0.137, \quad \Delta = 0.784 - 0.137 = 0.647

Step 3: Value is just the max.

V=0.7840.784×255=200 (OpenCV scale)V = 0.784 \quad \rightarrow \quad 0.784 \times 255 = 200 \text{ (OpenCV scale)}

Step 4: Saturation is the spread relative to the max.

S=Δmax=0.6470.784=0.8250.825×255=210S = \frac{\Delta}{\text{max}} = \frac{0.647}{0.784} = 0.825 \quad \rightarrow \quad 0.825 \times 255 = 210

Step 5: Hue depends on which channel was the max. Since red won:

H=60°×gbΔ=60°×0.1570.1370.647=60°×0.031=1.86°H = 60° \times \frac{g - b}{\Delta} = 60° \times \frac{0.157 - 0.137}{0.647} = 60° \times 0.031 = 1.86°

OpenCV halves it: Hcv=0.931H_{cv} = 0.93 \approx 1.

Now run the identical steps on the evening pixel (R=90R=90, G=18G=18, B=16B=16):

r=0.353,  g=0.071,  b=0.063,max=0.353,  Δ=0.290r = 0.353, \; g = 0.071, \; b = 0.063, \quad \text{max} = 0.353, \; \Delta = 0.290 V=0.353×255=90V = 0.353 \times 255 = 90 S=0.2900.353=0.822×255=210S = \frac{0.290}{0.353} = 0.822 \times 255 = 210 H=60°×0.0710.0630.290=60°×0.027=1.63°Hcv1H = 60° \times \frac{0.071 - 0.063}{0.290} = 60° \times 0.027 = 1.63° \rightarrow H_{cv} \approx 1

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: R=80R = 80, G=150G = 150, B=60B = 60.

r=0.314,  g=0.588,  b=0.235,max=0.588  (g),  Δ=0.353r = 0.314, \; g = 0.588, \; b = 0.235, \quad \text{max} = 0.588 \;(g), \; \Delta = 0.353

When green is the max, the hue formula shifts by 2:

H=60°×(brΔ+2)=60°×(0.2350.3140.353+2)=60°×1.776=106.6°H = 60° \times \left( \frac{b - r}{\Delta} + 2 \right) = 60° \times \left( \frac{0.235 - 0.314}{0.353} + 2 \right) = 60° \times 1.776 = 106.6°

Hcv=53H_{cv} = 53

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 Δ\Delta 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

GrayscaleBGR / RGBHSVLAB
Separates colour from brightnessN/ANoYesYes
Survives lighting changeNoNoMostlyWell
Distance matches human perceptionRoughlyPoorlyPoorlyWell
Cheap to computeYesFreeCheapCheap
Good forEdges, thresholds, shapeDisplay, model inputColour masks, sortingColour matching, CLAHE
Watch out forLoses all colour infoEvery channel moves with lightHue is unstable when dark or greyChannels are not intuitive
Picking a colour space for the job

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:

4×4 patch (4×4)
10
12
200
210
14
11
205
199
9
13
198
203
12
10
202
207

Use four bins, each 64 values wide:

BinRangeValues that land hereCount
00–6310, 12, 14, 11, 9, 13, 12, 108
164–127none0
2128–191none0
3192–255200, 210, 205, 199, 198, 203, 202, 2078

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:

Split (4×4)
10
10
200
200
10
10
200
200
10
10
200
200
10
10
200
200
Checkerboard (4×4)
10
200
10
200
200
10
200
10
10
200
10
200
200
10
200
10
Diagonal (4×4)
10
10
10
10
10
10
10
200
10
200
200
200
200
200
200
200

Count the values in each. Every one has exactly eight pixels at 10 and eight at 200. Their histograms are identical:

spatial information Where pixels are relative to each other. A histogram deliberately discards it, which is what makes histograms cheap and comparable but also blind to shape, texture and structure.

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.

σ=(10)2+(5)2+02+52+1025=2505=50=7.071\sigma = \sqrt{\frac{(-10)^2 + (-5)^2 + 0^2 + 5^2 + 10^2}{5}} = \sqrt{\frac{250}{5}} = \sqrt{50} = 7.071

Brightness: add a constant. Add 40 to every pixel:

100,105,110,115,120    140,145,150,155,160100, 105, 110, 115, 120 \;\rightarrow\; 140, 145, 150, 155, 160

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:

new=3(v110)+110\text{new} = 3(v - 110) + 110

10080,10595,110110,115125,120140100 \rightarrow 80, \quad 105 \rightarrow 95, \quad 110 \rightarrow 110, \quad 115 \rightarrow 125, \quad 120 \rightarrow 140

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 new=αv+β\text{new} = \alpha \cdot v + \beta:

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 β=220\beta = -220 comes from keeping the pivot fixed: to leave 110 unmoved while tripling, you need 1103(110)=220110 - 3(110) = -220.

Contrast has a hard limit: multiply too aggressively and values run past 255, where OpenCV clamps them. With α=3\alpha = 3, 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:

LevelCountRunning total (CDF)
333
458
5412
6416

Every pixel sits between 3 and 6, so the image looks flat and grey. The mapping formula is:

new level=round(CDF(v)CDFminNCDFmin×(L1))\text{new level} = \text{round}\left( \frac{\text{CDF}(v) - \text{CDF}_{min}}{N - \text{CDF}_{min}} \times (L - 1) \right)

With N=16N = 16, CDFmin=3\text{CDF}_{min} = 3, and L=8L = 8:

level 3round(3313×7)=0\text{level } 3 \rightarrow \text{round}\left(\frac{3-3}{13} \times 7\right) = 0 level 4round(8313×7)=round(2.69)=3\text{level } 4 \rightarrow \text{round}\left(\frac{8-3}{13} \times 7\right) = \text{round}(2.69) = 3 level 5round(12313×7)=round(4.85)=5\text{level } 5 \rightarrow \text{round}\left(\frac{12-3}{13} \times 7\right) = \text{round}(4.85) = 5 level 6round(16313×7)=7\text{level } 6 \rightarrow \text{round}\left(\frac{16-3}{13} \times 7\right) = 7

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.

Noisy window (3×3)
50
52
240
48
50
51
51
49
52
Mean kernel ÷9 (3×3)
1
1
1
1
1
1
1
1
1
Results (3×3)
mean
=
71.4
median
=
51
true
50

Mean. Sum the nine values: 50+52+240+48+50+51+51+49+52=64350 + 52 + 240 + 48 + 50 + 51 + 51 + 49 + 52 = 643.

mean=6439=71.4\text{mean} = \frac{643}{9} = 71.4

Median. Sort them: 48, 49, 50, 50, 51, 51, 52, 52, 240. Take the middle one:

median=51\text{median} = 51

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:

G(x,y)=ex2+y22σ2G(x, y) = e^{-\frac{x^2 + y^2}{2\sigma^2}}

For a 3×3 kernel with σ=1\sigma = 1, the offsets are 1,0,+1-1, 0, +1 in each direction:

  • Centre, (0,0)(0,0): e0=1.000e^{0} = 1.000
  • Edges, (±1,0)(\pm1, 0) and (0,±1)(0, \pm1): e1/2=0.607e^{-1/2} = 0.607
  • Corners, (±1,±1)(\pm1, \pm1): e2/2=0.368e^{-2/2} = 0.368

Sum of all nine: 1.000+4(0.607)+4(0.368)=4.8981.000 + 4(0.607) + 4(0.368) = 4.898. Divide each by that sum so the weights add to 1:

Raw Gaussian, σ=1 (3×3)
0.368
0.607
0.368
0.607
1.000
0.607
0.368
0.607
0.368
÷4.898 =
Normalized (sums to 1) (3×3)
0.075
0.124
0.075
0.124
0.204
0.124
0.075
0.124
0.075
Integer version ÷16 (3×3)
1
2
1
2
4
2
1
2
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 σ\sigma, not the kernel size. Larger σ\sigma 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)GaussianMedianBilateral
Removes Gaussian noiseYesYes, betterSomewhatYes
Removes salt-and-pepperNo, it smears itNo, it smears itYes, completelyPartly
Keeps edges sharpNoNoMostlyYes
Is it a convolution?YesYesNoNo
SpeedFastestFastMediumSlow
OpenCV callcv2.blurcv2.GaussianBlurcv2.medianBlurcv2.bilateralFilter
Four smoothing filters and what each is actually good at

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:

w(i,j)=ed22σs2how far×e(IiIj)22σr2how differentw(i, j) = \underbrace{e^{-\frac{d^2}{2\sigma_s^2}}}_{\text{how far}} \times \underbrace{e^{-\frac{(I_i - I_j)^2}{2\sigma_r^2}}}_{\text{how different}}

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.

Putting it together

Here is the preprocessing chain for the tomato line, in the order it runs.

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

  1. Photograph one coloured object under three lighting conditions: bright daylight, a dim room, and under a coloured lamp.
  2. Print the mean BGR and mean HSV of a small central patch in each photo. Confirm that hue moves least.
  3. Plot the grayscale histogram for each. Identify which one is under-exposed and check whether any pixels are clipped at 0 or 255.
  4. Add salt-and-pepper noise artificially and remove it with both GaussianBlur and medianBlur. 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.

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.

Test your understanding
Your outdoor camera detects orange safety vests using a fixed BGR rule. It works all summer, then starts missing vests on overcast days. What is the smallest change most likely to fix it?
Test your understanding
A thresholded image has thousands of isolated single white pixels scattered across the dark background. Which fix addresses the cause most directly?

Frequently asked questions

Should I convert to grayscale or keep colour?
Convert to grayscale when your rule depends on shape, edges, or texture, because it is simpler and about three times faster. Keep colour when the colour itself carries the answer, such as sorting by ripeness, spotting safety equipment, or reading colour-coded labels. For neural networks, keep colour unless you have a specific reason not to.
Why is OpenCV hue 0 to 179 instead of 0 to 359?
So it fits in a single unsigned byte alongside saturation and value. Every OpenCV hue value is exactly half the degree value, so pure red is 0, green is 60, and blue is 120 rather than 0, 120, and 240. Any hue range you copy from a colour picker needs halving before you use it with cv2.inRange.
When should I use CLAHE instead of equalizeHist?
Almost always. Global equalization uses one histogram for the entire image, so a single bright window or lamp distorts the mapping everywhere. CLAHE equalizes small tiles independently and caps the amplification, which handles uneven lighting and avoids exaggerating noise. Start with clipLimit=2.0 and tileGridSize=(8,8).
Does filtering help or hurt a deep learning model?
Usually it hurts slightly. Models learn to handle typical sensor noise on their own, and a fixed blur removes fine texture that the model might have used. The exception is when your noise is severe and structured, such as heavy compression artifacts or many dead pixels, in which case cleaning it first genuinely helps.
How do I know if my images are over-exposed?
Count the pixels at exactly 255 in the histogram. A handful is normal, for example a light bulb in frame. Thousands means the sensor saturated and the detail in those regions is gone. No software can recover it, so the fix is shorter exposure, a smaller aperture, or less light on the subject.
Start typing to search across all content
navigate Enter open Esc close