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.
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.
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.
Interestingly, it seems that the conditions about bits and angle limits the luminosity to ~0.25.