Search code examples
rustcolorsbit-manipulationrgbmaze

How do I find 4 complementary 24bit RGB colors with no overlapping bits?


I am seeking four colors that are distinguishable, somewhat complementary/visually appealing, and share no bits in common. What I mean by this is as follows:

  • Consider only the first 24 bits of a u32 and use them to represent an RGB color.
  • Select four colors in this manner.
  • The colors should have no bits in common for the following bitwise properties.

For example, if we treat these colors as unique identifiers then we should be able to mix these colors in interesting ways in a unsigned 32 bit integer. COLOR_1 would represent a different color than COLOR_1 | COLOR_2, and so on. Each of the four colors is unique and every possible combination is unique.

However, I have the additional constraint that no two bits among these four colors should overlap. That means if we have the following u32 let mix: u32 = COLOR_1 | COLOR_2 | COLOR_3;, then the following operation should be true: (mix & !COLOR_1) == COLOR_2 | COLOR_3. This is equivalent to removing COLOR_1 from the integer without affecting any bits belonging to COLOR_2 and COLOR_3.

I thought exploring all possible tetrads would be a good start (here is picture of a tetrad as well represented by the four points on this circle). However, I am not sure how to appropriately explore the RGB color space for a solution to my problem.

enter image description here

Is there a way to get something close to a tetrad with the properties I am looking for? Or maybe there is another color theory approach? The requirements overall are as follows:

  • 4 RGB colors represented in the low order 24 bits of a u32 such that no two colors share any bits in common.
  • Colors that are close to tetradic points that have a 90 degree angle towards each other. This is not a strict requirement as I am only seeking four colors that are easily distinguishable from one another with the bitwise properties from point 1.
  • Luminance is not a great concern but the colors should be visible against a black background.

Update: I removed my code as it incorrectly sought a solution and distracts from the great answer provided below.


Solution

  • This seems surprisingly tricky, mathematically. Am I understanding you right that you're looking for 4 colors which:

    • Bit-anding two of the colors in byte-based RGB is always 0
    • Have a 90° (or 180°) angle towards each other in HSL color space
    • The colors should be colorful / have high chroma (i.e. luminosity 0.5 and saturation 1 in HSL)

    The first is a hard requirement, the second and third probably not so much.

    I don't know how to come up with a set of such colors. You could brute-force this, as there's "only" ~10¹⁶·⁸ possibilities, but let's try something more interesting:

    You can randomly generate a set of 4 colors by just taking 24 1-bits and randomly assigning them to one of the 4 colors:

    fn generate_candidate(&mut self) -> [u32; 4] {
        let mut rng = thread_rng();
        let mut colorbits = [0u32; 4];
        for i in 0..24 {
            *colorbits.choose_mut(&mut rng).unwrap() |= 1 << i;
        }
        colorbits
    }
    // Yeah, this can't generate all possible combinations, it'll never have less than 24 1-bits. I don't think having less is useful.
    

    You can also generate a new similar candidate from an existing one by taking one bit and moving it to a different color

    fn tweak_candidate(&mut self, candidate: &[u32; 4]) -> [u32; 4] {
        let mut rng = thread_rng();
        let mut new = candidate.clone();
        let bit = rng.gen_range(0..24);
        // unset bit everywhere
        for c in &mut new {
            *c = *c & !(1 << bit);
        }
        // set bit in randomly chosen color
        *new.choose_mut(&mut rng).unwrap() |= 1 << bit;
        new
    }
    

    This is enough to allow walking through the space of possible colors quadruples. To find a good quadruples on that walk, you need to define what a good quadruples is, but you would have also had to do the same for brute-forcing:

    fn rank_candidate(&mut self, candidate: &[u32; 4]) -> f64 {
        let rgbs =
            candidate.map(|bits| Rgb::from(<[u8; 3]>::try_from(&bits.to_be_bytes()[1..]).unwrap()));
        let hsls = rgbs.clone().map(|rgb| Hsl::from(rgb));
        let mut hues = hsls.clone().map(|c| c.hue());
        hues.sort_by(f64::total_cmp);
        let angles: [f64; 3] = array::from_fn(|i| (hues[i + 1] - hues[i]));
        - angles.map(|a| (a - 90.).powf(2.)).into_iter().sum::<f64>() 
        - hsls
            .map(|c| (100. - c.saturation()).powf(2.) + (100. - c.lightness() * 2.).powf(2.))
            .iter()
            .sum::<f64>() * 0.1;
    }
    

    And with that, you can apply a metaheuristic like simulated annealing to find you a nice set of colors.

    Full code

    Interestingly, it seems that the conditions about bits and angle limits the luminosity to ~0.25.