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.

Note: Throughout this document, color components
(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:

  1. Read the input color in = (r, g, b, a).
  2. Map each channel through its dyad using interpolation.
  3. 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!

In html.txt form for easy AI sharing…