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:

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:

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:

    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.