Light and colour, read from an optician's source
Newton's experiments on the splitting of white light by a prism were first reported to the Royal Society of London in 1672, and his Opticks was published in English in 1704. In it he called the decisive experiment a "critical experiment", the experimentum crucis, and it showed that a colour of light corresponds to its degree of refrangibility, the angle at which it is bent, which no further reflection or refraction, and no coloured filter, could change. He named seven spectral hues: red, orange, yellow, green, blue, indigo and violet. Everything since, from the lens in a camera to the grey text on a web page that someone cannot read, is a calculation about how light is bent, how much of it there is and what a person makes of it.
The colour library for DKE, the Deterministic Knowledge Engine, writes down some of those calculations in DKE Python, and this essay teaches the subject by reading it. You record two kinds of fact. A colour pair is recorded by the relative luminance of its text and of its background, and the library's rules conclude which of the four contrast levels of the Web Content Accessibility Guidelines it clears and marks the ones it fails. A ray is recorded by the refractive index of the medium it leaves, the index of the one it meets and the sine of its angle of incidence, and a rule concludes whether it is totally reflected. The second half of the library calculates: sRGB luminance, the contrast ratio, hue, saturation and lightness, Snell's law, the critical angle, the thin lens, Wien's law and the frequency of a wavelength. Every program below runs as shown once the library's source file has been compiled into a store, and every output is copied from a run.
1. How much light a colour is
A colour on a screen is three numbers from 0 to 255, and they are not amounts of light. sRGB was first proposed by HP and Microsoft in 1996 and became the international standard IEC 61966-2-1 in 1999. The old cathode-ray tubes produced a brightness that followed a power law with an exponent of roughly 2 to 3, so the signals were encoded to compensate, and the encoding put the codes more densely near black, where the eye is more sensitive to changes in brightness. The result is that a channel of 128 is not half as bright as one of 255. To compute with light one first undoes the encoding. The standard's decoding is a straight line near black, dividing by 12.92 up to 0.04045, and a shifted power of 2.4 above it:
def srgb_linear(c: int) -> float:
s = c / 255.0
if s <= 0.04045:
return s / 12.92
return ((s + 0.055) / 1.055) ** 2.4The linear channels are then weighted, because the eye is not equally sensitive to the three primaries. The standard's values for the Y of its red, green and blue primaries are 0.2126, 0.7152 and 0.0722, and the relative luminance of a colour is their sum, from 0.0 for black to 1.0 for white:
return 0.2126 * srgb_linear(r) + 0.7152 * srgb_linear(g) + 0.0722 * srgb_linear(b)import colour
print(colour.luminance_hex("#000000"), colour.luminance_hex("#ffffff"), colour.luminance_hex("#777777"))
print(colour.srgb_linear(0), colour.srgb_linear(10), colour.srgb_linear(11), colour.srgb_linear(128), colour.srgb_linear(255))
print(colour.luminance(255, 0, 0), colour.luminance(0, 255, 0), colour.luminance(0, 0, 255))0.0 1.0 0.184474994500441
0.0 0.003035269835488375 0.003346535763899161 0.21586050011389926 1.0
0.2126 0.7152 0.0722A mid-grey of #777777 has a luminance of 0.18 and not 0.47, a channel of 128 gives 0.216, and the channels 10 and 11 lie on the straight part of the curve near black. A pure red is 0.2126 and a pure blue only 0.0722, which is the first thing to know about blue text: it is dark for its saturation. luminance_hex reads a colour written #rrggbb, and anything else it refuses.
2. Contrast
The contrast ratio of two colours is the luminance of the lighter plus 0.05 over the luminance of the darker plus 0.05, and it runs from 1 for two equal colours to 21 for black on white. The library has it as a script, and as a pair of rules for the levels of the guidelines:
def contrast_ratio(l1: float, l2: float) -> float:
if l1 >= l2:
return (l1 + 0.05) / (l2 + 0.05)
return (l2 + 0.05) / (l1 + 0.05)Success criterion 1.4.3 of the Web Content Accessibility Guidelines, level AA, asks for a ratio of at least 4.5 to 1 for text, and 3 to 1 for large-scale text, which it defines as at least 18 point or 14 point bold. The 3 to 1 is the minimum recommended by ISO 9241-3 and ANSI/HFES 100-1988 for standard text. The 4.5 to 1 accounts for the loss of contrast with moderately low visual acuity, colour deficiencies and age: 20/40 acuity is associated with a loss of contrast sensitivity of roughly 1.5, and 3 times 1.5 is 4.5. Level AAA asks for 7 to 1, which corresponds to roughly 20/80 vision, and 4.5 to 1 for large text. The ratios are thresholds and are not rounded: a computed 4.499 to 1 fails. Text that is part of a logo or brand name has no requirement, and neither has an inactive control or pure decoration. The library keeps the four levels as four conclusions of a pair, each with a rule for light text on a darker ground and one for dark text on a lighter:
@rule
def aa_light_text():
for p in Pair:
if p.fg > p.bg and p.ratio >= 4.5:
p.aa = TrueA pair that fails a level has no value for it, and is kept. Here are three pairs on white or on blue, and the reason the grey fails the first level:
import colour
colour.pair("grey", colour.luminance_hex("#777777"), 1.0)
colour.pair("ink", colour.luminance_hex("#000000"), 1.0)
colour.pair("white_on_blue", 1.0, colour.luminance_hex("#0000ee"))
print(colour.levels("grey"), colour.levels("ink"), colour.levels("white_on_blue"))
n = why_not(colour.Pair.grey.aa)
for line in str(n).split("\n"):
print(line)['large AA'] ['AA', 'AAA', 'large AA', 'large AAA'] ['AA', 'AAA', 'large AA', 'large AAA']
rule `colour.aa_dark_text` needs colour.Pair[grey].inverse >= 4.5, and colour.Pair[grey].inverse is 4.478089453577214 (real)
rule `colour.aa_light_text` needs colour.Pair[grey].fg > colour.Pair[grey].bg, and colour.Pair[grey].fg is 0.184474994500441 (real)Grey #777777 on white has a ratio of 4.478, just under the 4.5, so it clears only the levels for large text. The why_not names the two ways a pair could have cleared the first level, one for light text and one for dark, and what failed in each. Black on white clears all four, and so does white on #0000ee. A corrected fact withdraws what rested on it, and a second source is held beside the first:
import colour
colour.pair("grey", colour.luminance_hex("#767676"), 1.0)
print(colour.levels("grey"), colour.contrast_hex("#767676", "#ffffff"))
colour.pair("grey", colour.luminance_hex("#595959"), 1.0)
print(colour.levels("grey"))
update(colour.Pair.grey.fg, 0.3, "a second reading")
print(colour.unsettled(), colour.levels("grey"))['AA', 'large AA', 'large AAA'] 4.542224959605253
['AA', 'AAA', 'large AA', 'large AAA']
['pair grey fg'] ['AA', 'AAA', 'large AA', 'large AAA']Writing the grey again as #767676 replaces the first value, and the pair then passes the first level with 4.54 but not the AAA level of 7. Darker still, #595959 clears all four. The second source, which gives the text a luminance of 0.3, is written under another source and so replaces nothing: the pair has two readings of its text, unsettled() names the fact, and the levels are those that the darker reading supports, because a conclusion stands if any value on record supports it.
3. Hue, saturation and lightness
A designer does not think in the amounts of red, green and blue, but in a hue, how much of it and how light. HSV was introduced in the mid-1970s by people in computer graphics at PARC and NYIT, and described by Alvy Ray Smith in the August 1978 issue of Computer Graphics. HSL was described in the same issue by Joblove and Greenberg, who called its dimensions hue, relative chroma and intensity. Both rearrange the cube of the three channels into cylindrical coordinates, and they were cheap enough to compute in real time on the hardware of the decade. The hue is an angle: red is 0 degrees, yellow 60, green 120, cyan 180, blue 240 and magenta 300. The library computes the HSL of a colour given as three channels, with the saturation of HSL, and not the one of HSV:
import colour
print(colour.hsl(255, 0, 0), colour.hsl(255, 255, 0), colour.hsl(0, 255, 0))
print(colour.hsl(0, 255, 255), colour.hsl(0, 0, 255), colour.hsl(255, 0, 255))
print(colour.hsl(128, 128, 128), colour.hsl(0, 0, 238))
print(colour.luminance(0, 0, 255), colour.hsl(0, 0, 255)[2], colour.luminance(255, 255, 0), colour.hsl(255, 255, 0)[2])[0.0, 1.0, 0.5] [60.0, 1.0, 0.5] [120.0, 1.0, 0.5]
[180.0, 1.0, 0.5] [240.0, 1.0, 0.5] [300.0, 1.0, 0.5]
[0.0, 0.0, 0.5019607843137255] [240.0, 1.0, 0.4666666666666667]
0.0722 0.5 0.9278 0.5The six hues come out at 0, 60, 120, 180, 240 and 300 degrees, each with full saturation and a lightness of one half. A grey has the hue 0 and the saturation 0 by the library's convention, since it has no hue. The last line is the warning that goes with these models. HSL lightness is the mid-range of the largest and smallest channels, and it does not track perceived lightness: it diverges substantially from it, as the account of the models says. Pure blue and pure yellow both have a lightness of one half, and the luminance of the blue is 0.0722 and that of the yellow is 0.9278. This is why the contrast ratio is computed from luminance and not from the lightness of a model.
4. A ray at a boundary
The law of refraction has a long history of independent discovery. Ptolemy found an approximate relation between the angles, which failed for larger ones. Ibn Sahl in Baghdad found the law in 984, and used it to design lenses, though the attribution is disputed. Alhazen, the Latinized name of a scholar who lived from about 965 to about 1040, showed by experiment that light travels in straight lines, studied light passing through different materials and formulated laws of refraction. Thomas Harriot rediscovered it in 1602 without publishing, Willebrord Snellius derived an equivalent form in 1621, also unpublished in his lifetime, and René Descartes published it in La Dioptrique in 1637. Fermat reached the same result from his principle of least time, and Huygens explained it in 1678 from the wave nature of light. The law is that the ratio of the sines of the angles of incidence and refraction is the ratio of the refractive indices, and the library states it for the sine of the refracted ray, worked out from the three figures a ray is given:
sin_t: real = n1 * sin_i / n2The refractive index of a material varies with the wavelength of the light. At the sodium D-line, 589 nanometres, it is 1.333 for water, 1.52 for window glass, 2.417 for diamond and 1.000293 for air at 0 degrees Celsius and one atmosphere. Because blue light has the higher index it is bent more than red by a prism, which is how a prism splits white light. The library uses one index for a material and does not model the dependence on wavelength. When light goes into a medium of a lower index the law can ask for a sine above 1, which no angle has. The light is then reflected totally, and the largest angle of incidence that still refracts is the critical angle, the arcsine of the ratio of the indices:
@rule
def total_internal_reflection():
for r in Ray:
if r.n1 > r.n2 and r.sin_i <= 1.0 and r.sin_t > 1.0:
r.total = Trueimport colour
colour.ray("glass_air", 1.52, 1.0, 0.8)
colour.ray("water_air", 1.333, 1.0, 0.7)
colour.ray("air_glass", 1.0, 1.52, 1.0)
colour.ray("just_under", 1.52, 1.0, 0.65)
colour.ray("just_over", 1.52, 1.0, 0.66)
print(colour.total("glass_air"), colour.total("water_air"), colour.total("air_glass"), colour.total("just_under"), colour.total("just_over"))
a = why_not(colour.Ray.air_glass.total)
print(a)
b = why_not(colour.Ray.just_under.total)
print(b)
print(round(colour.critical_angle(1.52, 1.0), 2), round(colour.critical_angle(1.333, 1.0), 2), round(colour.critical_angle(2.417, 1.0), 2))
print(round(colour.refraction_angle(1.0, 1.333, 50.0), 2), round(colour.refraction_angle(1.0, 1.52, 30.0), 2))True False False False True
rule `colour.total_internal_reflection` needs colour.Ray[air_glass].n1 > colour.Ray[air_glass].n2, and colour.Ray[air_glass].n1 is 1.0 (real)
rule `colour.total_internal_reflection` needs colour.Ray[just_under].sin_t > 1.0, and colour.Ray[just_under].sin_t is 0.9880000000000001 (real)
41.14 48.61 24.44
35.08 19.2A ray from glass into air at a sine of 0.8 is totally reflected, and one from water at 0.7 is not. A ray from air into glass never is, since the rule needs the first index to be the larger, and the why_not says so. The two rays between them show where the line falls: from glass into air a sine of 0.65 refracts and 0.66 is reflected, and the critical angle of glass is 41.14 degrees, whose sine is 1 over 1.52. The critical angle of water is 48.6 degrees, as the account of the law gives it and that of diamond is 24.44 degrees, from the index 2.417. A ray from air at 50 degrees enters water at 35.08 degrees, and enters glass at 30 degrees as 19.2. The readings can be corrected like any others:
import colour
update(colour.Ray.water_air.sin_i, 0.8, "a second reading")
print(colour.unsettled(), colour.total("water_air"))['pair grey fg', 'ray water_air sin_i'] TrueThe second reading puts the ray from water at a sine of 0.8, which is above the sine of the critical angle, 0.75, and the ray is totally reflected under it. The first fact about the grey text is still on record, and it is still named.
Total internal reflection keeps light in an optical fibre. A fibre has a core surrounded by cladding of a lower refractive index, and light is kept in the core by total internal reflection. Daniel Colladon and Jacques Babinet first demonstrated guiding light by refraction in Paris in the early 1840s, and John Tyndall did so in public lectures in London about twelve years later. Charles Kao and George Hockham of Standard Telephones and Cables argued in 1965 that the attenuation in fibres could be reduced below 20 decibels a kilometre, if the impurities were removed from the glass, and Kao received the Nobel Prize in Physics in 2009 for it.
5. A thin lens
A thin lens gathers light from an object into an image, and the library uses the form in which the reciprocal of the focal length is the sum of the reciprocals of the object distance and the image distance. The sign convention is that of the real-is-positive form: a converging lens has a positive focal length, a real object a positive distance and a real image a positive distance, and a virtual image a negative one. The image distance and the magnification come from two short scripts:
return 1.0 / (1.0 / focal_length - 1.0 / object_distance)import colour
print(round(colour.lens_image(50.0, 2000.0), 2), round(colour.lens_magnification(50.0, 2000.0), 4))
print(colour.lens_image(50.0, 100.0), colour.lens_magnification(50.0, 100.0))
print(colour.lens_image(50.0, 25.0), colour.lens_magnification(50.0, 25.0))51.28 -0.0256
100.0 -1.0
-50.0 2.0A lens of 50 millimetres with an object two metres away gives an image 51.28 millimetres behind it, a little further than the focal length, and the image is inverted and shrunk to 0.0256 of the size of the object. With the object at twice the focal length the image is at twice the focal length too, the same size and upside down. With the object inside the focal length, at 25 millimetres, the image distance is negative: a virtual image, upright and twice as large, which is a magnifying glass. With the object at the focal point there is no image distance, since the image is at infinity, and lens_image refuses with a division by zero.
6. A hot body and its colour
Wilhelm Wien derived his displacement law in 1893, from a thermodynamic argument, and Max Planck later developed the more general radiation equation. The wavelength at which a black body radiates most strongly per unit wavelength is a constant over its temperature in kelvin, with the constant equal to about 2898 micrometre-kelvins. The library has it and the conversion of a wavelength to a frequency, from the speed of light, which is exactly 299,792,458 metres a second and has been the definition of the metre since 1983:
return 2.897771955e-3 / temperature_k
return 299792458.0 / (wavelength_nm * 1e-9)import colour
print(round(colour.wien_peak(5778.0) * 1e9, 1), round(colour.wien_peak(6000.0) * 1e9, 3), round(colour.wien_peak(2000.0) * 1e9, 1), round(colour.wien_peak(300.0) * 1e6, 2))
print(round(colour.frequency_hz(400.0) / 1e12, 1), round(colour.frequency_hz(500.0) / 1e12, 1), round(colour.frequency_hz(700.0) / 1e12, 1))501.5 482.962 1448.9 9.66
749.5 599.6 428.3With an effective temperature of 5778 K, the Sun radiates most strongly per nanometre near 500 nanometres, in the green part of the spectrum near the peak sensitivity of the human eye, and the library gives 501.5. At 6000 K the peak is 482.962 nanometres, a figure the account of the law gives too. The account of the law mentions an incandescent lamp dimmed to a colour temperature of about 2000 K, whose light shifts toward longer, redder wavelengths, and the library puts its peak at 1448.9 nanometres, in the infrared. A body at the temperature of the skin of a mammal, about 300 K, peaks near 10 micrometres, in the far infrared, and the library gives 9.66. The peak depends on how the spectrum is measured: per unit of frequency the Sun's peak lies near 883 nanometres, in the near infrared. The wavelengths of 400, 500 and 700 nanometres have frequencies of 749.5, 599.6 and 428.3 terahertz.
7. What the library leaves out
The library does not cover colour spaces other than sRGB, the saturation of the HSV model, spectra, the sign conventions of a diverging lens, or the dependence of a refractive index on wavelength. A critical angle exists only for light going into a rarer medium, and into a denser one critical_angle refuses, as the arcsine of a number above 1 has no answer. The luminance of a colour does not say how it looks next to others, which is what a designer knows and a number does not. And the levels it applies are the thresholds of one version of the guidelines, for a ratio computed in the way the guidelines define, and not a statement that text which clears them can be read by everyone.
Closing
Optics is old, and the history of light is a history of numbers found by several people in turn: Ibn Sahl's lenses, Snellius's sines, Newton's seven colours, Wien's constant. The library keeps their procedures where a reader can run them and ask about them. It names the rule a pair of colours failed and by how little, shows the line at which a ray stops leaving the glass, and holds two readings of one fact side by side when sources differ. And because each conclusion names the facts it rests on, a luminance corrected in one place is corrected in every level that rested on it.