Why selecting a colour is hard in RGB and easy in HSV, and what each channel actually holds.
Overview
RGB describes the hardware
A screen makes colour by mixing three lights. RGB records how much of each: three numbers from 0 to 255, one per main. It's the natural format for a display, a camera sensor and a file, and it's what almost each image arrives in.
It's a poor format for *thinking* about colour, and the three numbers aren't free in any way a person cares about.
Take a mid red, (200, 60, 60). Make it darker and you get (120, 36, 36) — all three numbers changed. Make it paler and you get (220, 140, 140) — all three again. The colour didn't change in either case. The brightness did, and then the purity did, and both ops moved each channel. There's no single number in RGB that means "how red" or "how bright".
Look at the R, G and B channels in the visualisation above. The red disc is bright in R, but so is the yellow one. Yellow is red plus green. None of the three channels isolates a colour, because none of them matches to any of it you'd name.
Colour Spaces: RGB and HSV
This module needs JavaScript: the
images are computed in the page rather than downloaded.
Worth knowing
RGB stores how much of each primary light to emit. It matches hardware, not perception.
In RGB, making a colour darker changes all three numbers, so brightness and colour are entangled.
HSV separates them: hue is which colour, saturation how pure, value how bright.
Selecting 'anything red' is one range in hue and three coupled ranges in RGB. That is the whole reason HSV exists.
Colour Spaces: RGB and HSV
The same pixel, given two ways. Why one of them makes colour pick trivial.
HSV separates the three questions
HSV rewrites the same pixel as three numbers that answer three separate questions.
Hue is which colour, as an angle around a wheel from 0 to 360 degrees. Red is near 0, green near 120, blue near 240. It's circular. 359 and 1 are neighbours.
Saturation is how pure the colour is, from 0 to 1. At 0 the pixel is grey even so of hue. At 1 it's the most vivid version of that hue available.
Value is how bright, from 0 to 1. At 0 the pixel is black even so of any of it else.
The conversion is math on the RGB triple. Value is the max channel, saturation is the range between max and min cut by the max, and hue is found by which channel is largest and by how the other two compare. No info is added or lost. It's the same pixel in different coords.
The thing this makes easy
Switch the visualisation to the H channel. Each disc turns a flat, uniform region, and each is a *different* flat region. That is the payoff: within one object, hue barely varies, even where brightness does. A shadow falling across a red ball changes V sharply and leaves H almost alone.
So "find the red object" turns one check:
mask = (hue < 15) or (hue > 345)
Try to write that in RGB. You need red high, green low, blue low — but how high, and scaled to what? A dark red has a low R. A pale pink has a high G and B. The ranges are coupled, they depend on lighting, and each new lighting rule needs them retuned. This is why colour-based tracking, chroma keying and skin spotting are all done in HSV or a close scaled, and why the technique is old enough to predate the tools you'd use for it now.
Saturation earns its place too. Hue is meaningless for a near-grey pixel — the math still makes an angle. But It's found by tiny differences and jumps around unstably. A handy mask so often needs saturation above some floor as well. This Reads as "a definite colour, not just a shade".
Task
Space
Why
Storing or displaying
RGB
matches sensors and screens
Selecting a colour
HSV
one range in hue
Adjusting brightness only
HSV
change V, leave H and S
Equalising contrast in colour
HSV or LAB
one brightness channel to equalise
Measuring perceptual difference
LAB
distances match what eyes report
Where HSV stops being enough
HSV is a convenience, not a visual model. Its value channel is simply the max of R, G and B. This Doesn't match how bright a colour looks: pure yellow and pure blue both have value 1 and are nowhere near equally bright to a human eye.
When the question is truly visual. How different do these two colours *look*, which of these is closer. The answer is a space like LAB, built from readings of human vision so that equal distances match to roughly equal seen differences. Colour-difference metrics and palette-matching work there.
The same pixel, converted
RGB stores three lights. HSV stores three answers to separate questions, and converting one pixel shows why the second is easier to reason about. Take a mid-red (200, 60, 60). Value is the largest channel over 255, saturation is the channel range over the max, hue is set by which channel leads:
component
computation
value
Value
200 / 255
0.78
Saturation
(200 − 60) / 200
0.70
Hue
red leads, so 60·((g−b)/range)
0°
No info is added or lost. It's the same pixel in different coords — but now "how red" is a single number. Darken the colour and only V moves. Wash it out and only S moves. The hue stays near 0 through both, which is just what makes "find the red object" one check, hue < 15 or hue > 345, where the RGB version needs coupled, lighting-dependent ranges on all three channels. Two traps come straight out of the math: hue *wraps*. So Red straddles 0 and needs two range tests, not one. And hue is meaningless when saturation is near zero (a grey pixel's hue is decided by rounding noise). So A real colour mask also needs S above a floor. And OpenCV packs hue into 0-179 to fit a byte, so half of each published threshold is wrong for it.
Where it goes wrong with Colour Spaces
Losing that hue wraps. Red straddles 0. A naive range test 10 < hue < 350 selects all except red. Two tests are needed, or a rotation of the hue axis first.
Skipping saturation. Grey pixels have an any hue. A hue-only mask picks up noise from each washed-out region in the frame.
given a fixed hue range transfers. Hue is stable against brightness, not against the colour of the light source. Photo the same red object under tungsten and daylight and the hue moves. White balance first, or calibrate per scene.
Reading OpenCV's hue as degrees. OpenCV stores hue in 0-179 so it fits in a byte, not 0-359. Half of each published threshold is wrong for this reason.
Questions people ask about Colour Spaces
Why use HSV instead of RGB for colour detection? In RGB, a single colour changes all three channels as lighting changes. In HSV the hue stays roughly constant, so one hue range picks out an object under varying light.
What do the H, S and V channels mean? Hue is the colour angle, saturation is how far it is from grey, and value is how bright it is.
Why is red hard to threshold in HSV? Hue is circular, so red sits at both ends of the scale, near 0 and near 180 in OpenCV. You need two ranges and combine their masks.
Why does OpenCV use hue values only up to 179? Hue is stored in 8 bits, and 360 degrees does not fit in 0 to 255. OpenCV halves it, so the full circle runs 0 to 179.
Why does my image look blue-tinted after loading it with OpenCV? OpenCV loads channels in BGR order while most plotting libraries expect RGB. Convert with cvtColor and COLOR_BGR2RGB before displaying.
When is Lab better than HSV? When you need distances between colours to match human perception. Lab is built so that equal numeric steps look like equal colour differences.
The same colour, described two ways
RGB says how much of each light. HSV says which colour, how vivid, how bright. Converting between them by hand shows why one is right for displays and the other for finding a coloured object.
example_01.pyNumPy
def rgb_to_hsv(r, g, b):
r, g, b = r / 255.0, g / 255.0, b / 255.0
mx, mn = max(r, g, b), min(r, g, b)
d = mx - mn
if d == 0:
h = 0.0
elif mx == r:
h = (60 * ((g - b) / d)) % 360
elif mx == g:
h = 60 * ((b - r) / d) + 120
else:
h = 60 * ((r - g) / d) + 240
s = 0.0 if mx == 0 else d / mx
return h, s, mx
swatches = [("pure red", (255, 0, 0)), ("dark red", (128, 0, 0)),
("pink", (255, 128, 128)), ("pure green", (0, 255, 0)),
("sky blue", (100, 180, 255)), ("white", (255, 255, 255)),
("grey", (128, 128, 128)), ("black", (0, 0, 0))]
print("%12s %18s %10s %10s %10s"
% ("colour", "RGB", "hue (deg)", "sat", "value"))
for name, (r, g, b) in swatches:
h, s, v = rgb_to_hsv(r, g, b)
print("%12s %18s %9.0f %10.3f %10.3f" % (name, str((r, g, b)), h, s, v))
print()
print("read the three reds. pure red, dark red and pink have completely")
print("different RGB triples, and the SAME hue of 0 degrees. they differ")
print("only in saturation and value:")
for name in ("pure red", "dark red", "pink"):
r, g, b = dict(swatches)[name]
h, s, v = rgb_to_hsv(r, g, b)
print(" %-10s hue %3.0f, saturation %.2f, value %.2f" % (name, h, s, v))
print(" that is the property the whole colour space exists for.")
Output
Hue is an angle on a colour wheel, and lighting changes barely move it, which is why HSV makes colour segmentation practical.
example_02.pyNumPy
import numpy as np
def rgb_to_hsv(r, g, b):
r, g, b = r / 255.0, g / 255.0, b / 255.0
mx, mn = max(r, g, b), min(r, g, b)
d = mx - mn
if d == 0: h = 0.0
elif mx == r: h = (60 * ((g - b) / d)) % 360
elif mx == g: h = 60 * ((b - r) / d) + 120
else: h = 60 * ((r - g) / d) + 240
return h, (0.0 if mx == 0 else d / mx), mx
print("HUE IS AN ANGLE, so the colours wrap:")
for deg, name in ((0, "red"), (60, "yellow"), (120, "green"),
(180, "cyan"), (240, "blue"), (300, "magenta"), (360, "red again")):
print(" %3d degrees -> %s" % (deg, name))
print(" hue arithmetic is circular. the distance between 350 and 10")
print(" degrees is 20, not 340:")
for a, b in ((350, 10), (10, 350), (100, 200)):
naive = abs(a - b)
circ = min(naive, 360 - naive)
print(" |%3d - %3d|: naive %3d, circular %3d %s"
% (a, b, naive, circ, " <- wrong" if naive != circ else ""))
print()
print("WHY THIS MATTERS: take a red object under three lighting conditions:")
lit = [("bright sun", (255, 40, 40)), ("indoors", (150, 25, 25)),
("shadow", (70, 12, 12))]
print("%14s %18s %28s" % ("condition", "RGB", "HSV"))
for name, (r, g, b) in lit:
h, s, v = rgb_to_hsv(r, g, b)
print("%14s %18s %10.0f %8.3f %8.3f" % (name, str((r, g, b)), h, s, v))
print()
rgbs = np.array([c for _, c in lit], float)
hsvs = np.array([rgb_to_hsv(*c) for _, c in lit])
print(" spread across the three, per channel:")
print(" RGB: R varies by %3.0f, G by %3.0f, B by %3.0f"
% tuple(rgbs.max(0) - rgbs.min(0)))
print(" HSV: H varies by %.1f, S by %.3f, V by %.3f"
% tuple(hsvs.max(0) - hsvs.min(0)))
print(" the hue barely moves. an RGB threshold has to cover a large")
print(" 3-D box to catch all three; a hue threshold is one narrow band.")
Output
A red detector built on hue handles lighting changes that break the same detector built on RGB thresholds.
example_03.pyNumPy
def rgb_to_hsv(r, g, b):
r, g, b = r / 255.0, g / 255.0, b / 255.0
mx, mn = max(r, g, b), min(r, g, b)
d = mx - mn
if d == 0: h = 0.0
elif mx == r: h = (60 * ((g - b) / d)) % 360
elif mx == g: h = 60 * ((b - r) / d) + 120
else: h = 60 * ((r - g) / d) + 240
return h, (0.0 if mx == 0 else d / mx), mx
lit = [("bright sun", (255, 40, 40)), ("indoors", (150, 25, 25)),
("shadow", (70, 12, 12))]
others = [("skin", (230, 180, 150)), ("orange", (255, 140, 0)),
("grey wall", (150, 150, 150)), ("dark blue", (20, 20, 90))]
print("%14s %18s %14s %14s" % ("sample", "RGB", "RGB rule", "hue rule"))
for name, (r, g, b) in lit + others:
h, s, v = rgb_to_hsv(r, g, b)
rgb_hit = r > 120 and g < 80 and b < 80
hue_hit = (h < 15 or h > 345) and s > 0.5 and v > 0.03
print("%14s %18s %14s %14s"
% (name, str((r, g, b)),
"MATCH" if rgb_hit else "-", "MATCH" if hue_hit else "-"))
print(" the RGB rule loses the object in shadow, because 'R > 120' is")
print(" really a brightness test. the hue rule catches all three.")
print()
print("THE PRACTICAL SPLIT:")
print(" RGB -- what a display emits and a sensor records. correct for")
print(" storage, transmission and as a neural network's input.")
print(" HSV -- separates WHAT COLOUR from HOW BRIGHT, which is what you")
print(" want for thresholding, chroma keying and colour tracking.")
print(" caveat: hue is undefined when saturation is 0:")
greys = {"white": (255, 255, 255), "grey": (128, 128, 128), "black": (0, 0, 0)}
for name in ("white", "grey", "black"):
r, g, b = greys[name]
h, s, v = rgb_to_hsv(r, g, b)
print(" %-8s saturation %.2f -> hue %.0f, meaningless" % (name, s, h))
print(" always gate a hue test on a minimum saturation, or grey pixels")
print(" will match whatever colour you were looking for.")
Output
Check yourself
0 of 3
Answer without scrolling back up.
Why is selecting 'anything red' easier in HSV than in RGB?
In RGB, brightness and purity change all three channels, so a red range is three coupled conditions. In HSV the colour is one number and lighting mostly moves the other two.
Why should a hue mask usually also require a minimum saturation?
When R, G and B are nearly equal, the hue angle is decided by tiny differences and jumps around. Requiring real saturation means requiring a definite colour.
What breaks a naive `10 < hue < 350` test for red?
That test selects everything except red. Because the hue axis wraps, red needs two ranges - near 0 and near 360 - or a rotation of the axis first.
Cheat sheet
Colour Spaces: RGB and HSV
Convert the same pixel between RGB and HSV, then see how a colour range that needs six RGB inequalities becomes a single HSV threshold.
Ashish Jangra builds and maintains VizLearn. Every module here is written and the visualisation behind it hand-built, so the numbers in a readout come from the same code that draws the picture. Corrections are genuinely welcome and get priority over everything else — if a page states something wrong, or an animation misrepresents what the algorithm does, get in touch.