Color Dyads and Channel-Based Mapping
This document introduces the idea of a color dyad and shows how to use it
in a simple channel-based image mapping process. All examples are written in
language-neutral pseudocode, so they can be implemented in any language.
1. What Is a Color Dyad?
A color dyad is simply a pair of colors:
// A dyad is a pair: (ColorA, ColorB)
Dyad = (ColorA, ColorB)
Think of it as a tiny color gradient with only two endpoints.
For an input value t in the range [0, 1], we interpolate:
Color Interpolate(Color A, Color B, number t in [0, 1]):
result.r = A.r * (1 - t) + B.r * t
result.g = A.g * (1 - t) + B.g * t
result.b = A.b * (1 - t) + B.b * t
result.a = A.a * (1 - t) + B.a * t
return result
When we feed a grayscale or normalized channel value into this interpolation,
we get a color somewhere along the dyad.
(r, g, b, a) are assumed to be in the range [0, 1].
2. Using Dyads Per Channel
Suppose we have an image with pixels containing (r, g, b, a), each in [0, 1].
We can assign a separate dyad to each channel:
// One dyad for each input channel
DyadR = (ColorR_A, ColorR_B) // used with input r
DyadG = (ColorG_A, ColorG_B) // used with input g
DyadB = (ColorB_A, ColorB_B) // used with input b
For each pixel, we:
- Read the input color
in = (r, g, b, a). - Map each channel through its dyad using interpolation.
- Combine the three resulting colors into a single output.
2.1 Pseudocode for Channel-Based Dyads
Image ProcessWithChannelDyads(Image inputImage,
Dyad DyadR, // (ColorR_A, ColorR_B)
Dyad DyadG, // (ColorG_A, ColorG_B)
Dyad DyadB): // (ColorB_A, ColorB_B)
width = inputImage.width
height = inputImage.height
outputImage = NewImage(width, height)
for y from 0 to height - 1:
for x from 0 to width - 1:
inPixel = inputImage.getPixel(x, y)
r = inPixel.r // [0, 1]
g = inPixel.g // [0, 1]
b = inPixel.b // [0, 1]
a = inPixel.a // [0, 1]
// Map each channel through its dyad
pseudoR = Interpolate(DyadR.A, DyadR.B, r)
pseudoG = Interpolate(DyadG.A, DyadG.B, g)
pseudoB = Interpolate(DyadB.A, DyadB.B, b)
// Combine them (simple average)
outColor.r = (pseudoR.r + pseudoG.r + pseudoB.r) / 3
outColor.g = (pseudoR.g + pseudoG.g + pseudoB.g) / 3
outColor.b = (pseudoR.b + pseudoG.b + pseudoB.b) / 3
outColor.a = a // preserve original alpha
outputImage.setPixel(x, y, outColor)
return outputImage
Many variations are possible: you can average the colors as shown,
or use channel weights, or even choose just one of the pseudo colors.
3. Example Dyad Configurations
Here are some concrete dyad setups that a reader can try.
Colors are written as (r, g, b, a) in [0, 1].
Example A – Warm Highlights, Cool Shadows
// Dark cool blue to bright warm yellow
DyadR.A = (0.0, 0.0, 0.2, 1.0) // deep blue
DyadR.B = (1.0, 0.9, 0.4, 1.0) // warm yellow
DyadG.A = (0.0, 0.0, 0.2, 1.0)
DyadG.B = (1.0, 0.9, 0.4, 1.0)
DyadB.A = (0.0, 0.0, 0.2, 1.0)
DyadB.B = (1.0, 0.9, 0.4, 1.0)
// Use the same dyad for all three channels
output = ProcessWithChannelDyads(input, DyadR, DyadG, DyadB)
This produces a coherent, single-gradient recoloring:
darker regions move toward deep blue, highlights toward warm yellow.
Example B – Split Complementary Channels
// Each channel has a different dyad, giving more complex mixtures.
// Red channel: purple → orange
DyadR.A = (0.5, 0.0, 0.5, 1.0)
DyadR.B = (1.0, 0.5, 0.0, 1.0)
// Green channel: teal → lime
DyadG.A = (0.0, 0.5, 0.5, 1.0)
DyadG.B = (0.6, 1.0, 0.4, 1.0)
// Blue channel: navy → cyan
DyadB.A = (0.0, 0.0, 0.3, 1.0)
DyadB.B = (0.0, 0.9, 1.0, 1.0)
output = ProcessWithChannelDyads(input, DyadR, DyadG, DyadB)
Here, each input channel pushes the pixel toward a different zone in color space.
Averaging them creates rich, layered hues.
Example C – Randomized Dyads (Experimental)
You can also randomize your dyads within certain ranges to explore many mappings:
function RandomColor(maxBrightness):
c.r = Random(0, maxBrightness)
c.g = Random(0, maxBrightness)
c.b = Random(0, maxBrightness)
c.a = 1.0
return c
for iteration from 0 to N - 1:
// Example: weaker "A" colors, stronger "B" colors
weightA = 0.5
weightB = 1.0
DyadR.A = RandomColor(weightA)
DyadR.B = Normalize(RandomColor(weightB)) // Optional normalization
DyadG.A = RandomColor(weightA)
DyadG.B = Normalize(RandomColor(weightB))
DyadB.A = RandomColor(weightA)
DyadB.B = Normalize(RandomColor(weightB))
output = ProcessWithChannelDyads(input, DyadR, DyadG, DyadB)
SaveImage(output, "output_" + iteration + ".png")
This produces a set of images, each with its own idiosyncratic color world.
4. Beyond Dyads: Expanding to Palettes
A dyad is a palette of length 2. We can generalize this to a full
palette: an ordered array of colors.
// Palette is an array of colors: [C0, C1, ..., Cn-1]
Palette = array of Color
We then stretch our input value t in [0, 1] across the entire array.
4.1 Interpolating Across a Palette
Color SamplePalette(Palette P, number t in [0, 1]):
n = length(P)
if n == 0:
return (0, 0, 0, 1) // or some default
if n == 1:
return P[0]
// Scale t to segment index
scaled = t * (n - 1)
indexLow = floor(scaled)
indexHigh = min(indexLow + 1, n - 1)
localT = scaled - indexLow // fractional part in [0, 1]
return Interpolate(P[indexLow], P[indexHigh], localT)
Now, instead of a dyad per channel, we can define a palette per channel.
4.2 Palette-Based Channel Mapping
Image ProcessWithChannelPalettes(Image inputImage,
Palette PaletteR,
Palette PaletteG,
Palette PaletteB):
width = inputImage.width
height = inputImage.height
outputImage = NewImage(width, height)
for y from 0 to height - 1:
for x from 0 to width - 1:
inPixel = inputImage.getPixel(x, y)
r = inPixel.r
g = inPixel.g
b = inPixel.b
a = inPixel.a
pseudoR = SamplePalette(PaletteR, r)
pseudoG = SamplePalette(PaletteG, g)
pseudoB = SamplePalette(PaletteB, b)
outColor.r = (pseudoR.r + pseudoG.r + pseudoB.r) / 3
outColor.g = (pseudoR.g + pseudoG.g + pseudoB.g) / 3
outColor.b = (pseudoR.b + pseudoG.b + pseudoB.b) / 3
outColor.a = a
outputImage.setPixel(x, y, outColor)
return outputImage
Example D – Multi-Stop Palette per Channel
// Example palette with 4 colors: black → blue → magenta → white
PaletteR = [
(0.0, 0.0, 0.0, 1.0),
(0.0, 0.0, 0.6, 1.0),
(0.6, 0.0, 0.6, 1.0),
(1.0, 1.0, 1.0, 1.0)
]
// Use same palette for all channels
PaletteG = PaletteR
PaletteB = PaletteR
output = ProcessWithChannelPalettes(input, PaletteR, PaletteG, PaletteB)
Here, the input intensity walks through multiple “stations” in color space,
giving more nuanced control than a simple dyad.
5. Summary
- A color dyad is a simple two-color gradient,
perfect for mapping a single scalar value in [0, 1] to color. - Assigning a separate dyad (or palette) per channel lets you build complex,
expressive color relations from simple building blocks. - Replacing dyads with palettes extends the idea so that
the input range [0, 1] is stretched across an entire color array. - These techniques are language-agnostic and can be implemented anywhere
you can read and write pixels.
If you found this valuable, look forward to the NewAge project with a complete color/geometry api symbolically tuned blitters and MUCH more!