The goal of this PR is to remove obstacles for the sampling and better gathering of the colors, making a much more pleasant and "readable" (see #188) colorscheme palettes. This change, requires a lot of internals changes, API breaks, and so on. This is why, it marks a tremendous change code wise, but also in the results perceived as a user.
WIP: Redesign of color gatherings (fixes #189) #195
master into v3
modify the current one to fit the new structure
ping @usaradark , given your interested, would like your feedback of the current design. I didn't quite had much time, but I come with this for now.
I now imagine the palettes as functions that continue changing the "histogram" according to some ord (dark/light) and it's own goal (maybe is an ANSI palette, "soft" dark or the like, but this is for the end)
I will take a deeper look into it with the coming days. It's kind of late for me right now. But here's a few comments. Also heads up, I'm still very much on the early stages of my Rust journey so I can't help too much on Rust-specific nuances.
Not too sure what the full scope, and very much could be thinking out of scope, but...
/// TODO how effective is this approach? I've tested this with lab previously, see
/// colorspaces::dedup_cols
fn dedup(&mutself){// self.histo.sort_by_key(|a| a.color.chroma as i32);
self.histo.iter_mut().dedup_by_with_count(|a,b|a.color.diff(&b.color,self.threshold,&self.mode)).for_each(|x|x.1.count+=x.0);}
I never really thought about about it, but could you explain why there is a gather-merge step AND a dedup step? Isn't the merge step the dedup step? If colors have been merged with threshold 10, then wouldn't a dedup step with the same threshold be redundant? They also use the same comparison color.diff.
Like say we only had brightness on a range from 0 to 9 and a threshold of 2.
The image has the following colors.
0 1 2 3 5 7 9
Bucketing would look like this.
hist: [bucket[color, count]]
histo = [[0, 3], [3, 2], [7, 2]]
Then we have the dedup step. With the current histo being as follows, how would it dedup? It wouldn't do anything as far as I'm aware. Maybe it's more important when the mix flag is used?
So the more I think about sampling, the more I think the original-original design doesn't support it. Truncating kills the pool of still perfectly-good colors.
Let's assume a monochromatic image that only had brightness values from 0 to 9.
If we truncate to only 3 colors, depending on the theme (say dark), we might end up with the following.
7 8 9
Now if we sample this, we don't have much to sample from. We've truncated the pool of colors to sample from, but those colors we truncated are actually still perfectly usable (assuming the histogram generated colors correctly).
So a more useful pool of colors to sample from, generated from the histogram, might look something like this, and should NOT need any truncating at all.
3 4 5 6 7 8 9
And we'll say the background is around 1, and that 3 is enough contrast for SampleLow to work.
Which leads into the next point. Histogram generation should generate colors that, when sampled, guarantees a minimum legible contrast from the background. Again, this allows SampleLow to work, or whatever the heck sampling method we decide on (i.e. completely user defined dynamic).
All of this is to suggest that, unless we have a very specific reason that we NEED to throw away those lower colors, I think we should keep them i.e. maybe remove truncation all together... though maybe truncation in a different context...
I know in #189 I brought up the problem of the histogram keeping colors that only had a very low counts (e.g. <10 on a large image). Obviously these colors aren't really a major part of the image and might be considered noise. This possibly might be another threshold we or the user defines, on how "prominent" does a color have to be for it to be accepted into the final histogram. This could be a better trunc() step, as in truncate colors that aren't important to the image i.e. have very low counts.
...
Why do we limit to only 16 colors...?
If I were to sum up those initial thoughts...
- Sampling requires a substantial pool of colors to sample from, generated by the histogram.
- Sampling should not need to further process the colors (except to generate
bgand maybecol0,col7,col8, andcol15). - All color transformations and filtering should be done at the histogram step.
- Thoughts suggest the following pipeline:
gather->merge->dedup?->filter- The filter step does the following:
- Filter colors that would be too close to the background given the
ColorOrder - Filter colors that have rather low counts (might be noise or unimportant parts of the image)
- Maybe not truncate only to 16 colors, keep all colors so sampling has a good pool to choose from
- Filter colors that would be too close to the background given the
- The filter step does the following:
I also noticed you have a SchemeSort. I'm not too sure where this is going, but sorting based on salience != luminance of course... is it going to make use of the Difference trait? I vaguely remember calling for sort over and over again when I wrote Salience. This might be some Rust thing I'm not familiar with right now.
Alright, it's late, brain fried. Until next time.
Why do we limit to only 16 colors...?
Yes, truncation can be omitted. I liked that idea.
gather -> merge -> dedup? -> filter
What's merge? I've combined read_bytes and gather since there is no point in being divided (also bc of the type system :P). So the pipeline looks like:
Convert bytes to a histogram -> Post processing, probably filter out really annoying colors like pitch black and white and other colors, unless it's monochrome -> dedup (?, can be discussed later) -> sorting the colors in a way that makes sense (dark/light/ansidark , etc)
My current question is, do you require the histogram to sort the colors? as in, is the .score or .count information useful when organizing the color palette? In my previous implementations no, I've tried to divide those steps. However, it does kinda make sense in scenario in which there are multiple palettes. Like, what if I want some kind of "Nord" like theme? That could be done artificially, maintaining some histo information.
In that sense, I'm thinking more that the palettes become modifiers. Or rather, there could be 2 palettes: dark and light, and modifiers that, maybe, can be combined. Like palette = dark and modifier = SampleLow or modifier = ansi and so on. This would depend on the internals, since there would probably exist a case that couldn't be combined. js throwing out some ideas..
gather -> merge -> dedup? -> filter
What's merge? I've combined
read_bytesandgathersince there is no point in being divided (also bc of the type system :P). So the pipeline looks like:
Sorry I didn't make it clear. merge maybe wasn't the right term here. I kind of meant the initial bucketing. The initial bucketing in a way "merges" the close colors together. Dedup does that too, does it? In that cause, wouldn't dedup be redundant? Not too sure why we have two "merges".
My current question is, do you require the histogram to sort the colors? as in, is the .score or .count information useful when organizing the color palette? In my previous implementations no, I've tried to divide those steps. However, it does kinda make sense in scenario in which there are multiple palettes. Like, what if I want some kind of "Nord" like theme? That could be done artificially, maintaining some histo information.
So the reasoning why I had the weird sorting during the histo step was actually a workaround for the 16 color truncation step.
.score is a measure of how "prominent" a color is to an image. It was created to solve #189 (drop low prominent colors), but if I developed any further it would I would be overstepping into the underlying infrastructure, hence we are here. It was mainly an issue when the total buckets was less than 16, hence those lower counts would be let through.
So it was required NOT to sort the colors, but it IS required to filter low prominent colors.
From our discussions, I think we're leaning towards a direction where histo's responsibility is to return colors that are significant (prominent) to the image, bucketed appropriately to get some pool of colors to sample from.
I'm also not too sure how maintaining histo information would be required for generating a Nord-like theme. I actually have no idea what you mean by Nord. Like arctic cool-blue colors?
However, there is a very big question on where, and if we do, what we filter. See below.
In that sense, I'm thinking more that the palettes become modifiers. Or rather, there could be 2 palettes: dark and light, and modifiers that, maybe, can be combined. Like palette = dark and modifier = SampleLow or modifier = ansi and so on. This would depend on the internals, since there would probably exist a case that couldn't be combined. js throwing out some ideas..
I think you make a very, very important point.
So let's say the user wants to generate a dark theme. Right now, colors from a pool (P) that clash with the dark background get removed during the histo phase. If we wanted to re-use (P) to generate a light theme, it would not be sufficient because during the histo step, we filtered out the darkest colors. We would have to regenerate the histo to get those dark colors. This suggests that the histo phase should not filter colors to allow palette to modify and sample appropriately.
This also allows caching to work rather well. If we cache a good pool of colors generated from the histo, all sampling types that we have (or maybe the user defines...) just sample from that pool. Currently, for all the sampling methods for Salience, it redoes it from the very beginning; a lot of wasted compute.
If the histo step IS to filter any colors, it would probably very similar to the filter you defined, removing colors of very low chroma or of very extreme whiteness or blackness.
All of this is to say that filtering and sorting might actually be responsible for the palette stage, or perhaps somewhere in-between histo and palette.
If we think ahead...
- histo is to return all "significant" bucketed colors of the image <--- maybe cache here
- minimally filter for egregious (very bad) colors <--- maybe cache here too?
- filter colors that clash against user theme (too dark if dark theme, too light if light theme.
- sorting depends on the theme (light or dark), but also the sorting method (luminance or salience) <--- maybe cache here as well
- modify colors (i.e. ansi, nord, soft, hard) <--- maybe cache too?
- sample colors <-- maybe cache here if computations from previous cache is rather large
When I think of caching, I think of the operations that take the longest. Dynamic thresholding is probably what it is, and if all steps after generating the histo is computationally trivial, then maybe that's all that needs to be cached. Like we don't really need to cache sample high and sample low, just cache the pool that it samples from. idk... maybe just cache at the steps where the user can make a decision.
edit - rewording, clarifying why salience histo sorts the way it does
You can run it with cargo run -- run "$(readlink -f $WALLPAPER)" -wuT -t 17 And change the threshold, dynamic one is left at the end.
Got the first salience version working. Think I kinda like where it goes, but would like it to be a bit more different. However, the approach is clean enough. Is either a dark or light palette, that both should impl. Now, the palettes could have modifiers, which change the pipeline.
Since this is Rust, I'm very attracted to the
histogram.with_threshold(30).with_scheme(Dark).with_modifier(Ansi).with...And so on. I'm leaving this to the end. Don't think much about cache btw, at least not now. I think storing the first histogram is enough. Even on large images, the file is relatively short. So, I imagine, with even more histogram process, the elements would be minimal. I prefer that recompute than to lose precision.
I'm also not too sure how maintaining histo information would be required for generating a Nord-like theme. I actually have no idea what you mean by Nord. Like arctic cool-blue colors?
Prominent colors, intermediate colors, either based on count or score, can be placed to simulate a theme. Rather than, "just the brightess at 15th position", just place them following an order, and then manually tweak them. Hence, a modifier for original colors. 1
Anyway, let's not get into modifiers right now, your pipeline help me get this right. For consistency sake, I've implmemented this into one function. I think we can treat the threshold problem now, since that is a requirement for the dynamic implementation.
Edit: showoff ur salience work
salience-working
-
https://www.nordtheme.com/docs/colors-and-palettes I like this descriptions. ↩︎
Threshold problem, I'm going to assume it's what I was talking earlier about thresholding against DeltaE vs ImprovedDeltaE and how it can be... odd. I'm going to reiterate some of the stuff I said in other threads to try to centralize the points.
DeltaE is the real distance between a two colors. The scale is rather linear.
https://docs.rs/palette/0.7.6/src/palette/cam16/ucs_jab.rs.html#201-212
ImprovedDeltaE is the perceptual distance between two colors. The scale is non-linear, using power-law scaling. This is to say that at some point, "more different" is meaningless. It's like you're rich, but then you get richer; doesn't matter, you're rich.
The official crate implementation for Cam16 raises it to a power of 0.63 and multiplies the entire thing by 1.41 such that a value of 1.0 describes the point of Just Noticeable Difference (JND)... or at least that's my understanding of it.
https://docs.rs/palette/0.7.6/src/palette/cam16/ucs_jab.rs.html#201-212
This means that the perceptual delta of each color difference method is the same at 1.0, but values beyond that quickly diverge due to their computation.
Now the problem comes with how we a user would "naturally" threshold. For numerical thresholding, I would say most humans do it linearly. So while the user (and dynamic) sets linear threshold values, the implementation of color differences actually uses non-linear values.
This results in a case where the user sets values around >28, but seemingly has no affect because ImprovedDeltaE saturates at that point. But also, because the range of ImprovedDeltaE is [0, 28](?) compared to DeltaE's range of [0, 100](?) means that threshold values are rather sensitive, especially given the former's non-linear scaling nature.
This can also be problematic for dynamic thresholding because it searches by a linear binary search, then once a floor is found, it linearly increases threshold until some max value(?). The starting value is also rather high for ImprovedDeltaE as the "middle" is more like 7(?), while for DeltaE the "middle" is more like 40(?).
// start from the middle, and then search either upper or lower values, as a simple bin tree
letidx=[14,16,13,17,12,18,11,19,10,20,9,21,8,22,7,23,6,24,5,25,4,26,3,27,2,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44];So the main questions to answer are the following.
- Do we use linear scaling? Non-linear scaling?
- Only one or the other?
- If we use both, when should we use one or the other?
On the main branch, I believe thresholding is put up against ImprovedDeltaE. There are a few solutions we might be able to take...
- Have scaling be linear for everything
- This means differences are no longer perceptually accurate at large differences.
- This does NOT affect sorting.
- Can make constraints for color mods a bit more difficult as it's no longer mapped to perception (e.g constrain
bgto haveDeltaEof20). But this can be solved by usingImprovedDeltaEandDeltaEappropriately...?
- Let the user do linear scaling, but map their linear-scaling to match the non-linear scaling
- This map will vary from colorspace to colorspace, as the scaling is not the same across all colorpalettes (e.g. Lch uses
powf(0.55)and a multiplier of1.26to bring the JND to1https://docs.rs/palette/0.7.6/src/palette/lab.rs.html#266-274)
- This map will vary from colorspace to colorspace, as the scaling is not the same across all colorpalettes (e.g. Lch uses
Edit: showoff ur salience work
Ayo not gonna lie, that image is a really good demo of Salience. You got the muted colors which should be sorted lower and the and the vibrant eye-catching lights in the front. I'm curious what the colors in the histogram looks like and how it sorted. Did you ever get a debug print working? I'll post my debug print in the end and you can work with that if you want.
- https://www.nordtheme.com/docs/colors-and-palettes I like this descriptions. ↩︎
That's very interesting... I think it's certainly possible to have something like this.
If you do not care about the original Nord colors and only want to follow the structure/method of it, I'd imagine that after a low-threshold histogram of colors, the user defines an array of their target salience values or modifiers. Something like...
# define the nord "sampling method" template
# values the percentage-index from left-to-right of a sorted array of colors
nord = [
5, 6, 7, 8, # polar night
30, 32, 34, # snow storm
40, 42, 44, 46, # frost
70, 74, 76, 78, 80 # aurora
]
Or, with built-in color filtering/modding to better match Nord's inner workings (i.e. nord1 is a brigher shade of nord0)
# define the nord "sampling method" template
# values the percentage-index from left-to-right of a sorted array of colors
nord = [
5, 5:brighten(0.05), 5:brighten(0.10), 5:brighten(0.15), # polar night
...
]
However, if you DO care about the original nord colors, you run into a similar issue to ANSI, how you have to mix but somewhat retain the original hue of the original. That's a can of worms I'm not ready for yet. I haven't read how you managed to do it.
edit
Print colors with their index, 10 per row
pubfn print_visual(cols: &[Spec]){useowo_colors::OwoColorize;letcols: Vec<Myrgb>=cols.iter().map(|&c|c.into()).collect();println!();letchunk_size=10;for(chunk_index,chunk)incols.chunks(chunk_size).enumerate(){letstart=chunk_index*chunk_size;// Print indices
foriin0..chunk.len(){ifi==0{print!("{:^3}",(chunk_index)%10);}else{print!("{:^3}",(start+i)%10);}}println!();// Print values
forvalinchunk{print!("{}","".on_color(val.owo_col()));}println!();println!();}}As for the table... I'm sure you know I disgustingly hacked that thing together.
// This giant block is sort_histo_by_score, but with a debug table to
// manually validate calculated score values. You can delete this later
// once score_histogram_by_score is finalized. Though any changes
// here and there are not mirrored, you will have to manually do clone
// the changes. I'm sure there's a more elegant way to do this...
letmuthistoscore: Vec<HistoScore<Spec>>=histo.into_iter().map(|h|h.into()).collect();letcnt_sclr=4.0;letsal_factor=1.0/20.0;histoscore.iter_mut().for_each(|hs|{letscore_log_scaled=(hs.histo.countasf32).powf(1.0/cnt_sclr);letcol=hs.histo.color;hs.score=score_log_scaled*(1.0+col.improved_salience(&bg)*sal_factor);});histoscore.sort_by(|a,b|b.score.partial_cmp(&a.score).unwrap_or(std::cmp::Ordering::Greater));println!();// histoscore.insert(0, bg_histo.into());
histoscore.insert(0,max_histo_og.into());//
letcols: Vec<Spec>=histoscore.iter().map(|hs|hs.histo.color).collect();print_visual(&cols);println!();//
println!("HISTOSCORE");// println!("{:>2} {:>7} {:>7} {:>7} {:>7} {:>7} {:>7} {:>7} {:>7} {:>7} {:>7} {:>7}", "i", "l", "c", "h", "cnt", "cnt_log", "base", "c_sal", "h_sal", "sal_n", "sal", "scr");
println!("{:>2}{:>7}{:>7}{:>7}{:>7}{:>7}{:>7}{:>7}{:>7}{:>7}{:>7}","i","l","c","h","cnt","cnt_log","base","c_sal","h_sal","sal_n","scr");histoscore.iter().enumerate().for_each(|(i,hs)|{letcnt=hs.histo.count;letcnt_log=(hs.histo.countasf32).powf(1.0/cnt_sclr);letcol=hs.histo.color;letl=col.lightness;letc=col.colorfulness;leth=(360.0+col.hue.into_raw_degrees())%360.0;letcsal=c_sal(&col,&bg);lethsal=h_sal(&col,&bg);letsal=col.improved_salience(&bg);letsal_naive=col.improved_salience_naive(ord);letbase=col.improved_salience(&bg);letscore=hs.score;// println!("{i:>2} {l:>7.2} {c:>7.2} {h:>7.2} {cnt:>7} {cnt_log:>7.2} {base:>7.2} {csal:>7.2} {hsal:>7.2} {sal_naive:>7.2} {sal:>7.4} {score:>7.4}");
println!("{i:>2}{l:>7.2}{c:>7.2}{h:>7.2}{cnt:>7}{cnt_log:>7.2}{base:>7.2}{csal:>7.2}{hsal:>7.2}{sal_naive:>7.2}{score:>7.4}");});println!();histoscore.remove(0);letmuthisto: Vec<Histo<Spec>>=histoscore.into_iter().map(Histo::from).collect();// END DEBUG PRINT
between methods, temporarily
Print colors with their index, 10 per row
Done. I'm leaving it out behind a debug compiler flag
But this can be solved by using ImprovedDeltaE and DeltaE appropriately...?
Yes. from your comment I got so much insight on it. While on a dynamic search for a 'perfect' or 'best' threshold, use linear, to 'allow' more precision or possibilities of 'finding' the right spot. Just define a common function and give a parameter to change to choose between improved or default impls.
Let the user do linear scaling, but map their linear-scaling to match the non-linear scaling
I think this is also correct, but would be perceive as "nothing happens" by the users. Instead, we can just declare that the threshold is limited by an interval, [0,28> roughtly.
Now, another attempt would be to scale it to 0-100%, and let the decimals make the difference.
This requires testing to determine a good enough approach.
Edit: Or if decimals matter as the overall "difference" in between colors (probably in very large images) or if they matter in the question I had below, to determine a perfect threshold
I think the codebase is much more cleanear now, you really did a good job on getting this to work in the past implementation. If you do get the urge to tweak, use the config to change mode = "salience" or mode = "luminance". This is here for testing only. There is a missing piece, which is determining when to use salience or luminance. This is very important, since this allowed me to make the Build trait infallible or 'pure'. My initial thought on this previous method was to determine if it's monochromatic or not. Even then, hell you could probably make a palette with a single color pixel, and just interpolate it with either a predetermined white or black color constant. These scenarios could make unnecessary a dynamic threshold and a salience histogram. The other use of luminance, in that sense, it just to sort based on, luminance. I haven't tweak luminance yet, more excited about these other stuff rn, but soon will rebuild it like the colorspace one.
Question I have now is... How to determine a good threshold on the salience approach? With my previous impl it was simple, since it gather into histos and then dedup it, with diff methods, most colors just disappear and I could simple just do until MIN_COLS or whatever has the most colors.
I really invite you to test this one out, the print_values + dynamic threshold searching is kinda cool. Pushing the limits of saliency
Edit2: Maybe I should js rayon all the iter()s kek..
I will have more time in the coming days to try it out. What I really wanted to do is to create a tool to quickly test constants for salience calculations and sorting. This way I can get some more datasets on what might be the right constants. The constants used for salience calculations are a very coarse "binary search" that I did.
Question I have now is... How to determine a good threshold on the salience approach? With my previous impl it was simple, since it gather into histos and then dedup it, with diff methods, most colors just disappear and I could simple just do until MIN_COLS or whatever has the most colors.
Because the original implementation had 16 as MAX_COLS, that was what I went with; the threshold that creates a histogram with about 16 buckets. If 32 colors generated, they get clipped to 16, meaning only 50% of the histogram-image representation could be used. If only 8 colors were generated, while the histogram fully represents the image, sampling is limited and narrow. But, because we are now removing this constraint (because it had no real reason to exist), this best-threshold approach is no longer valid.
Threshold, at least for the initial bucketing, is to merge similar colors together. This is to say that at low thresholds, the perceptual difference between two colors are low, while at high thresholds, the individual colors get more and more distinct.
My understanding is that all contrast calculations that at least follow some standardized method (most known contrast methods) follow the rule that 1.0 stands for Just Noticeable Difference (JND), with further elaboration on the color difference wikipedia. However, beyond that 1.0 JND, the scale is NOT standardized in any way. ImprovedDeltaE might be standardized across implementations, though this needs confirmation.
When considering for sampling, if we SampleHigh, we don't want the top 8 colors to all be some variation of neon pink. A best-threshold would have it such that colors are distinct enough from each other, but not so high that actual distinct colors become merged. I haven't tested using DeltaE, but at least for ImprovedDeltaE, the best value to get 16 colors is about 11. That threshold was before the removal of MAX_COLS, so... maybe try 4 and move up from there? idk I'll maybe try some testing on my end as well for that.
I may have also manually adjusted the value returned from diff_col or w/e that function was to get a reasonable amount of colors returned given the default dynamic-threshold search values.
I think this is also correct, but would be perceive as "nothing happens" by the users. Instead, we can just declare that the threshold is limited by an interval, [0,28> roughtly.
What I meant by this is like...
letrescaled_threshold=1.41*threshold.powf(0.63)This lets the user use threshold linearly as if it were DeltaE, but it's actually scaled to perceptual difference ImprovedDeltaE. Previously, without this scaling and when using ImprovedDeltaE as difference logic, "nothing happens". This rescale would fix it, but then you might as well just be using DeltaE on difference logic.
There is a missing piece, which is determining when to use salience or luminance. This is very important, since this allowed me to make the Build trait infallible or 'pure'.
I didn't get the chance to look at the fallback logic for monochromatic images. But do remember that salience on Cam16 is quite literally a weighted average of Jab. Which is to say that if you took away weights for ab, you get Lightness. While not exactly the same thing as luminance, it might be more perceptually accurate over just bucketing with luminance.
These scenarios could make unnecessary a dynamic threshold and a salience histogram. The other use of luminance, in that sense, it just to sort based on, luminance. I haven't tweak luminance yet, more excited about these other stuff rn, but soon will rebuild it like the colorspace one.
That brings up a very good question. What did dynamic thresholding solve, and do we need it now if we're doing a Sample step at the end of the pipeline to generate the palette? Because with a sane image and a sane default threshold, the generated histogram should be robust enough to sample from. You might not even need to deal with multithreading anymore.
I would imagine that if less than... idk 24 (8*3) colors are generated then a monochromatic fallback may be considered so that sampling has some range to work with.
edit - brain tired but can actually do some more work on this in the coming days.
Because the original implementation had 16 as MAX_COLS, that was what I went with; the threshold that creates a histogram with about 16 buckets. If 32 colors generated, they get clipped to 16, meaning only 50% of the histogram-image representation could be used. If only 8 colors were generated, while the histogram fully represents the image, sampling is limited and narrow. But, because we are now removing this constraint (because it had no real reason to exist), this best-threshold approach is no longer valid.
I think you choose your colors based on index? Not sure, but that was the straightfowards approach to choose 6 colors. With salience there are the modifiers, so we can get rid of the truncation overall.
That brings up a very good question. What did dynamic thresholding solve, and do we need it now if we're doing a Sample step at the end of the pipeline to generate the palette? Because with a sane image and a sane default threshold, the generated histogram should be robust enough to sample from. You might not even need to deal with multithreading anymore.
To just run with the best option, assume an uninterested user about this color theory stuff, as this is how many of us start the "just works" stage. While, initially I did implement a dynamic threshold, first "automatic" as I called it, to just hardcode a threshold to each colorspace. Still, raises the question to... which palette is actually the "better" one?
I have time now and for the next two days (hopefully) to work on this so I will look into some method of finding this.
I think you choose your colors based on index? Not sure, but that was the straightfowards approach to choose 6 colors. With salience there are the modifiers, so we can get rid of the truncation overall.
By index as in the upper 6, lower 6, center 6, or distributed 6, yes by index.
Still, raises the question to... which palette is actually the "better" one?
Assuming what's printed on the console is a single histogram... that's probably way too many buckets. I don't know what you mean by better. What am I comparing this to?
What I'm going to do, once I finish some other stuff, is to create a few tools to help us better understand what thresholds we need. Something like "create a demo palette where for each palette, it is at least X threshold away from the previous color". It should help us understand more about how threshold (ImprovedDeltaE and DeltaE) actually contribute to the visual differences.
What I'm predicting is that there's a strange issue where hue-shifts dominate lightness/colorfulness-shifts. In other words, colors of similar hue do not get bucketed due to differing lightness and colorfuless when instead they should. If this is so, this might suggest the need for a new heuristic for bucketing colors. Maybe like bucket much more aggressively on lightness and colorfulness.
Assuming what's printed on the console is a single histogram... that's probably way too many buckets. I don't know what you mean by better. What am I comparing this to?
No, it's all the threshold table from the previous dynamic multithreadead implementation, so there are multiple histograms. There is a lot more not shown in the screen, but, like I said, which one is the "good" one?
What I'm predicting is that there's a strange issue where hue-shifts dominate lightness/colorfulness-shifts. In other words, colors of similar hue do not get bucketed due to differing lightness and colorfuless when instead they should. If this is so, this might suggest the need for a new heuristic for bucketing colors. Maybe like bucket much more aggressively on lightness and colorfulness.
So another filter? sounds good, that's what I wanted to do in the old lch colorspace impl, but there wasn't a concrete type to work on and store constant data, problem you've experience with implementing salience with a Lock and a global var. With the new impl, ofc, this is no longer an issue, so you have way more 'space' to work on specific ideas.
Btw, I was trying to reach a "complete" state on the structural rewrite and realize that, there is no need of a dark/light implementation for salience, it's just based on ColorOrder, am I right? In that case, it could even make things more simpler, I was working with a new enum type with Dark and Light variants, but seems unnecessary.
Something I've come to understand as I kept on tinkering with salience on Lch is that when considering human perception of color, Lch kind of sucks for the perceptual accuracy I needed to get salience working. Cam16 is nearly better in always in that all components are much more, is not are, perceptually linear compared to Lch. Lch also struggles in low light, underestimates blue hues, and overestimates yellow hues.
Me knowing that Cam16 fixes all of that, I find it hard to recommend continuing with Lch. I cannot think of a scenario where Lch would perform better than Cam16 unless computation is a big issue, which Cam16 most definitely has more computation. But even when testing lch vs salience on the master branch, there is little-to-no difference computation times of trials I ran; they are neck-and-neck.
If we wish to change the sorting method, then we just change the heuristic on how sorting is used, no need to change the entire colorspace.
I bring this up because the way salience buckets is DeltaE or ImprovedDeltaE, NOT with salience. The latter is exclusively used for palette sorting. luminance, or rather lch, also uses the same bucketing method. This is redundancy, and their implementation only really diverges on the sorting step, as early-sorting due to trunc() is no longer needed. See the next section on that.
Because we are no longer truncating, sort_by_score() for salience.post_dedup() actually serves no purpose anymore. Honestly I think the entirety of post_dedup() can be removed... at least for salience. Again, this was here to prevent colors from being truncated during the trunc() step by moving more interesting colors upwards on the sort.
We still need to sort the colors somewhere, either by salience or luminance as you mentioned earlier. I want to say use salience first as it includes lightness, colorfulness, AND hue, while luminance is, well, just luminance. Sure you could exclusively use luminance for greyscale, but it will not work for monochromatic as chroma and luminance varies there.
The only place where luminance is sound (valid) is greyscale... unless the user explicitly wants to sort by luminance and not salience, I think salience sort should cover most user expectations... but then again, this whole salience thing is somewhat novel(?) in the palette generation space. That said, I still think this better matches the intention of what users actually want rather than what they think they would want. Hopefully that makes sense.
@explosion-mental wrote in #195 (comment):
Btw, I was trying to reach a "complete" state on the structural rewrite and realize that, there is no need of a dark/light implementation for salience, it's just based on
ColorOrder, am I right? In that case, it could even make things more simpler, I was working with a new enum type withDarkandLightvariants, but seems unnecessary.
This is correct... to an extent. ColorOrder is used to determine what the initial background is (light/dark).
(削除) There is a pre-sort to determine which color is closest to that generic safe initial background, then we choose the closest color, constrain it as a background, then do another sort to finalized the palette. (削除ここまで)
So it IS used, but only once and then never again.
edit - which color is closest, not which background is closest, typooo
edit - correction
Actually, the MOST PROMINENT COLOR is chosen as the background. Then, depending on if dark or light, it constrains that color accordingly, then sorts. After that, ColorOrder is never used.
@explosion-mental wrote in #195 (comment):
No, it's all the threshold table from the previous dynamic multithreadead implementation, so there are multiple histograms. There is a lot more not shown in the screen, but, like I said, which one is the "good" one?
You need to label which palettes belong to which thresholds. There's no way for me to determine which row belongs to which threshold.
The way I wrote the print function, how you're supposed to read it as is as follows.
ten's digit place
| single's digit place
| |
v v
0 1 2 3 4 5 6 7 8 9 <-- 0 to 9
00 01 02 03 04 05 06 07 08 09 <-- read like
colors here
1 1 2 3 4 5 6 7 8 9 <-- 10 to 19
10 11 12 13 14 15 16 17 18 19 <-- read like
colors here
I am SO sorry I did not make that very clear. I don't know if it works properly beyond 100 colors... lol
edit addition
So I don't know what threshold you're using, but it goes all the way to the 90's digit, and possibly loops and goes beyond 100. What threshold are you using here? I would imagine you get that many colors in the histogram if you used a threshold like 5 or something.
Here is a more useful post_dedup() for salience that properly does a pre-sort. Replace at line 242 on histogram/salience.rs.
fn post_dedup(&mutself){// Make the most prominent (most count) color as the background
// i.e. if blue themed wallpaper, then blue theme!
let(idx,_)=self.histo.iter().enumerate().max_by_key(|(_,item)|item.count).unwrap();letmax_histo=self.histo[idx];letbg=max_histo.color;letcams: Vec<Spec>=self.histo.iter().map(|h|h.color).collect();letbg=self.constrain_col_as_bg(bg);letres=constrain_col_against_cols(bg,&cams,&self.ord,&[self.bg_min_sal()]);letbg=res[0];self.histo.sort_by(|a,b|improved_salience(&a.color,&bg).partial_cmp(&improved_salience(&b.color,&bg)).unwrap_or(Ordering::Equal));#[cfg(debug_assertions)]println!("salience-sorted in post_dedup()");#[cfg(debug_assertions)]self.print_visual();}At threshold 17, generated 20 colors (index 0-19).
image
edit addition:
Also, the second print_visual() was placed post-histo_sort(). Because it was a histo-sort and not a salience-sort, it is difficult to tell which palette is actually the better one, because it isn't actually intuitively sorted by visual color alone (as an entire table of color count, score, and salience is missing).
Try it with this and you should be able to better grasp what would be a good threshold.
@usaradark wrote in #195 (comment):
@explosion-mental wrote in #195 (comment):
No, it's all the threshold table from the previous dynamic multithreadead implementation, so there are multiple histograms. There is a lot more not shown in the screen, but, like I said, which one is the "good" one?
You need to label which palettes belong to which thresholds. There's no way for me to determine which row belongs to which threshold.
The way I wrote the print function, how you're supposed to read it as is as follows.
ten's digit place | single's digit place | | v v 0 1 2 3 4 5 6 7 8 9 <-- 0 to 9 00 01 02 03 04 05 06 07 08 09 <-- read like colors here 1 1 2 3 4 5 6 7 8 9 <-- 10 to 19 10 11 12 13 14 15 16 17 18 19 <-- read like colors hereI am SO sorry I did not make that very clear. I don't know if it works properly beyond 100 colors... lol
edit addition
So I don't know what threshold you're using, but it goes all the way to the 90's digit, and possibly loops and goes beyond 100. What threshold are you using here? I would imagine you get that many colors in the histogram if you used a threshold like 5 or something.
Let me add the threshold, it was just a demostration that, there is, in fact, a lot of "good enough" palettes created by salience, which increases the difficulty of looking for the "best one" (whatever that means).
Maybe, a mega resize to the image to get the most prominent pixels? This fails because prominent != salient necessarily. So, this question is up to test, once again.
@usaradark wrote in #195 (comment):
Something I've come to understand as I kept on tinkering with salience on Lch is that when considering human perception of color, Lch kind of sucks for the perceptual accuracy I needed to get salience working. Cam16 is nearly better in always in that all components are much more, is not are, perceptually linear compared to Lch. Lch also struggles in low light, underestimates blue hues, and overestimates yellow hues.
Me knowing that Cam16 fixes all of that, I find it hard to recommend continuing with Lch. I cannot think of a scenario where Lch would perform better than Cam16 unless computation is a big issue, which Cam16 most definitely has more computation. But even when testing
lchvssalienceon the master branch, there is little-to-no difference computation times of trials I ran; they are neck-and-neck.If we wish to change the sorting method, then we just change the heuristic on how sorting is used, no need to change the entire colorspace.
I bring this up because the way salience buckets is
DeltaEorImprovedDeltaE, NOT withsalience. The latter is exclusively used for palette sorting.luminance, or ratherlch, also uses the same bucketing method. This is redundancy, and their implementation only really diverges on the sorting step, as early-sorting due totrunc()is no longer needed. See the next section on that.Because we are no longer truncating,
sort_by_score()forsalience.post_dedup()actually serves no purpose anymore. Honestly I think the entirety ofpost_dedup()can be removed... at least forsalience. Again, this was here to prevent colors from being truncated during thetrunc()step by moving more interesting colors upwards on the sort.We still need to sort the colors somewhere, either by
salienceorluminanceas you mentioned earlier. I want to say usesaliencefirst as it includes lightness, colorfulness, AND hue, whileluminanceis, well, just luminance. Sure you could exclusively useluminancefor greyscale, but it will not work for monochromatic as chroma and luminance varies there.The only place where luminance is sound (valid) is greyscale... unless the user explicitly wants to sort by luminance and not salience, I think salience sort should cover most user expectations... but then again, this whole salience thing is somewhat novel(?) in the palette generation space. That said, I still think this better matches the intention of what users actually want rather than what they think they would want. Hopefully that makes sense.
That is true, and, now that I think about it, if you really want to, you could just sort Salience by lightness. Let's leave the monochromatic edge case to the end. With this conclusion, traits can be removed, and implementations can be done directly to Salience type.
@explosion-mental wrote in #195 (comment):
Let me add the threshold, it was just a demostration that, there is, in fact, a lot of "good enough" palettes created by salience, which increases the difficulty of looking for the "best one" (whatever that means).
Maybe, a mega resize to the image to get the most prominent pixels? This fails because prominent != salient necessarily. So, this question is up to test, once again.
That's actually kind of crazy now that I tried it myself.
(top-half-ish)
image
This is with a threshold of 5. Wherever we sample, each color is still rather distinct from each other EXCEPT the very end where there's a lot of consecutive similar colors.
(scroll down)
image
Threshold 9 seems to have them merged, at least for this background image I'm using.
image
After testing it on several other images, 9~12 seems to be the sweet spot, with 9 being on the riskier side of not merging colors that should be merged because they visually appear similar.
I do wonder if bucketing by salience rather than DeltaE would be a good idea... initially I thought it wasn't, hence why I never did it, but I never tried...
edit - Hm... this "best threshold" is harder as it seems, as you said...
use -k for dynamic or -t 17 or whatever threshold you want
I do wonder if bucketing by salience rather than DeltaE would be a good idea... initially I thought it wasn't, hence why I never did it, but I never tried...
Yeah, curiosity! :]
I think it can be tweaked if the changes are not "good" overall.
Here's an interesting experiment I just ran. Threshold for this one is 18 to try to fit it on one screen.
- Initial bucketing
- Lightness-sorted
- Colorfulness-sorted
- Hue-sorted
- Salience-sorted
2025年12月08日T18:35:19,506923300-08:00
Of these sorting methods, which one would you best be able to tell if colors are too similar from each other and need to be merged?
For me, it's when it's hue-sorted.
But is this discovery useful? Well, this is to suggest that we more easily group (and merge) colors that are closer in hue rather than lightness or chroma. If you ask someone to group colors together, they would 99% probably group them by hue, not by lightness or colorfulness.
This may also inversely suggest hues dominate contrast more than I initially thought. This will probably be important in the next salience-calculation implementation. It's possible I may need to do similar math as I did on Lch, though using a simpler model.
I don't think this is too useful in determining the "best" threshold, as what really matters at the end is salience-sorting.
...
Actually... this is a very kind of big. For bucketing, hue is VERY important, then lightness, then colorfulness. If we do a new heuristic to improve bucketing, we would need to merge colors where hue is similar, which means a custom diff function will need to be written.
I can look to do this after this redesign gets finalized. For now, just use ImprovedDeltaE. It will work good enough.
As for the best threshold, I think the best we can do right now is to play it safe. A threshold too low can result in colors too-similar neighboring each other. This causes problems when, of course, sampling colors adjacent to each other, which is how Low, Balanced, and High works. Maybe a value of 12 is decent, but a value of 15 is probably extra safe.
@explosion-mental wrote in #195 (comment):
I do wonder if bucketing by salience rather than DeltaE would be a good idea... initially I thought it wasn't, hence why I never did it, but I never tried...
Yeah, curiosity! :]
I think it can be tweaked if the changes are not "good" overall.
Bucketing by salience doesn't sit right with my gut. Salience is meant to describe how much "attention grabbing" a color is compared to another color (background). For bucketing which compares a color to all other colors and groups them, it doesn't make sense.
Histogram bucketing is essentially grouping similar colors together, and from the earlier experiment, humans intuitively group colors MUCH MORE by hue than lightness or colorfulness. I think we need a very specific function, like is_similar_to() that puts much more weight on hue than lightness or colorfulness. DeltaE and ImprovedDeltaE do a good job at mathematically and realistically providing a value of difference, and even though Cam16Ucs has components that are perceptually linear, its DeltaE weights all components equally, which may not be applicable to what we're doing.
The scope grows...
edit - added context by quote
@usaradark wrote in #195 (comment):
Here's an interesting experiment I just ran. Threshold for this one is 18 to try to fit it on one screen.
* Initial bucketing * Lightness-sorted * Colorfulness-sorted * Hue-sorted * Salience-sorted2025年12月08日T18:35:19,506923300-08:00
Of these sorting methods, which one would you best be able to tell if colors are too similar from each other and need to be merged?
For me, it's when it's hue-sorted.
But is this discovery useful? Well, this is to suggest that we more easily group (and merge) colors that are closer in hue rather than lightness or chroma. If you ask someone to group colors together, they would 99% probably group them by hue, not by lightness or colorfulness.
This may also inversely suggest hues dominate contrast more than I initially thought. This will probably be important in the next salience-calculation implementation. It's possible I may need to do similar math as I did on Lch, though using a simpler model.
I don't think this is too useful in determining the "best" threshold, as what really matters at the end is salience-sorting.
...
Actually... this is a very kind of big. For bucketing, hue is VERY important, then lightness, then colorfulness. If we do a new heuristic to improve bucketing, we would need to merge colors where hue is similar, which means a custom
difffunction will need to be written.I can look to do this after this redesign gets finalized. For now, just use
ImprovedDeltaE. It will work good enough.As for the best threshold, I think the best we can do right now is to play it safe. A threshold too low can result in colors too-similar neighboring each other. This causes problems when, of course, sampling colors adjacent to each other, which is how
Low,Balanced, andHighworks. Maybe a value of12is decent, but a value of15is probably extra safe.
I kind of got this result with the lightness one, as my testing increased with changing from LAB to LCH. I like the salience sort btw. These algos can have an interesting use cases, maybe use darker color for a variant with dark colors, like my harddark old impl.
I can look to do this after this redesign gets finalized. For now, just use ImprovedDeltaE. It will work good enough.
This will sit no worries. You can PR to the new-histo branch and continue. There are some caveats I need to address as well
BTW, since there are many colors, would be interesting to get different 16 colors. Not all can be different, like the 16 colors filter, they could simple be a variation of the latter. Just a thought. Right now, I'm not sure if I should remove dedup and/or truncate overall ? Given that you are editing it
@explosion-mental wrote in #195 (comment):
BTW, since there are many colors, would be interesting to get different 16 colors. Not all can be different, like the 16 colors filter, they could simple be a variation of the latter. Just a thought. Right now, I'm not sure if I should remove dedup and/or truncate overall ? Given that you are editing it
I won't be touching that tonight. I'll be working on a better bucketing heuristic, so feel free to continue to reorganize things. I will be making an entirely new function, like Spec.similar_to() for bucketing purposes.
I think it's good that we have many colors. Especially since they are all actually somewhat distinct; salience-sort is doing it's job. It's just that the merge should be better. Merge is not the same as salience sort.
I like the idea of having slightly different variations of colors for an image. If someone really doesn't like the colors samples, easy, they just need to sample very slightly differently to get a similar effect. I know I've had a similar issue where the colors picked are like "bleh."
to linear or not..
PR automatically created by Woodpecker CI Co-authored-by: WoodpeckerCI <woodpecker@codeberg.org> Reviewed-on: #193
Update to use the monthly updated rust toolchain https://mynixos.com/fenix
Because we're using DeltaE rather than Improved, make sure you are using a threshold higher than you would usually use. If you usually use 15, try bumping that up to 30+.
ImprovedDeltaE soft-caps around 20. An Improved value of 20 in DeltaE might be around 50. iirc black vs white results in a DeltaE of close to 100. I would have to validate this, it's based on rough memory and a one-off experiment.
This pull request serves to bucket colors more intuitively to how humans perceive the difference in colors. Currently, colors are bucketed via `DeltaE` or its `Improved` variant. `DeltaE` describes the mathematical difference between a color's components with another color. While this is mathematically accurate, it isn't perceptually accurate or intuitive to how humans would bucket colors. This results in bucketing where neighboring similar-appearing colors of the same hue are not bucketed due to differing lightness or colorfuless. This PR proposes a specialized heuristic for bucketing colors. The general idea is is that humans tend to bucket (group) colors based on hue first, then lightness, then colorfulness. The latter two components may be swapped depending on results. The actual implementation idea is to put less weight on hue differences when doing `diff()`, though when walking through the code, `diff()` might actually make more sense to be named `similar()` based on what it is calculating. I will elaborate more on this once I better understand what the actual logic and intentions are. Reviewed-on: #197 Co-authored-by: uwidev <btyang24@gmail.com> Co-committed-by: uwidev <btyang24@gmail.com>
Was there a specific reason why print_visual() was implemented specifically on Salience and not as a generic pub fn? I'm having some issues utilizing it to validate that salience values are correct outside of the Salience.histo context.
@usaradark wrote in #195 (comment):
Was there a specific reason why
print_visual()was implemented specifically onSalienceand not as a genericpub fn? I'm having some issues utilizing it to validate that salience values are correct outside of theSalience.histocontext.
Nope, easy of use really.
while it acts for the salience methods, it's thought as the intermediate represenattion of colors in between raw bytes and a whole palette
and change salience with ansi
essentials into the mod.rs, show important stuff at the top, and other refactors. No behaviour change
This allows a much simpler control with adding stuff and handling the configurations of the internals. Also, it's more clearer what is used where (in the histogram module, specifically).
versions. Temporarily allow unused
Older wallust version used the hard/soft terms. Now, "soft" is pastel and "hard" is vibrant.
Given the nature of how salience chooses colors, with the Sampling enum, it doesn't require to return exactly 16 colors, or even 6, since they are choosen by centroids/median or whatever the Sampling is.
the palette itself
It isn't entirely correct because the usual Dark (also meaning SoftDark,
HardDark) has been replaced by salience approach. So the `Salience` +
something variants are the default ones. The `Ansi` variant is now
another mode, since the palette keyword in the config has either salience or
ansi.
Some examples:
With
```toml
style = "dark"
salience = { sampling = "balanced", intensity = "normal" }
```
have SalienceDark
If I use
```toml
style = "dark"
salience = { sampling = "distributed", intensity = "pastel" }
use16cols = true
```
it results in SalienceDarkDistributed16 + SoftDark
and so on. We can finally 'let this be'
Now that I think about it, I might push this into another branch instead of master, and have something like v4-alpha, given that config and cli flags are still subject to change. If I find more "modes" or more "palettes" (in the new definition), like the "nord" like them I mentioned, it might require more tweaking, meaning more specialized internals or exposure etc etc.
tldr; probably doing an alpha testing for tinkerers since user configs might change.
the struct has potential to be used outside of simple salience gatherings
@explosion-mental wrote in #195 (comment):
The goal of this PR is to remove obstacles for the sampling and better gathering of the colors, making a much more pleasant and "readable" (see #188) colorscheme palettes. This change, requires a lot of internals changes, API breaks, and so on. This is why, it marks a tremendous change code wise, but also in the results perceived as a user.
I've achieve this goal basically for histogram. I'm reusing this branch for a "v4-alpha pre-release", given that it's basically complete. This is because there are other stuff to clean up given the removal of colorspaces and the replacement of palettes. Also, some changes could affect the histogram, so the branch should cover the changes.
Some comments to save for future work:
For a "Nord like" theme: #195 (comment)
About the threshold scaling: #195 (comment)
No due date set.
No dependencies set.
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?