Add harmonize utils

This commit is contained in:
Schluffe
2026-10-01 00:04:46 +02:00
parent 3b72cd5723
commit f9fee06d37
7 changed files with 1243 additions and 79 deletions
+84 -11
View File
@@ -31,7 +31,9 @@ cargo run --release -- /path/to/screenshot.png
## Color grouping and mapping ## Color grouping and mapping
**Group similar colors** is enabled by default in the **Palette**. Nearby shades **Group similar colors** is enabled by default in the **Palette**. The default
**Edited** view shows the resulting colors. Switch to **Source / map** to select
original colors for replacement. Nearby shades
from scaling or compression share a row showing their combined percentage and from scaling or compression share a row showing their combined percentage and
shade count. Adjust **Tolerance** (default 8, range 0–32) to control the maximum shade count. Adjust **Tolerance** (default 8, range 0–32) to control the maximum
difference in each RGB channel from the group's most frequent color. Groups do difference in each RGB channel from the group's most frequent color. Groups do
@@ -39,7 +41,8 @@ not chain together through intermediate shades. Turn grouping off or set toleran
to zero for the exact palette. Hover a row to highlight its pixels in magenta in to zero for the exact palette. Hover a row to highlight its pixels in magenta in
the crop preview; this temporary highlight is never exported. the crop preview; this temporary highlight is never exported.
Freeze a capture or import an image, then click **Map** beside a palette row. Freeze a capture or import an image, choose **Source / map**, then click **Map**
beside a palette row.
In **Color mapping**, click its replacement swatch to choose another color or enter In **Color mapping**, click its replacement swatch to choose another color or enter
RGB values. Every original shade in that group maps to the chosen replacement. RGB values. Every original shade in that group maps to the chosen replacement.
Add multiple mappings to try a new palette. Each mapping keeps its original group Add multiple mappings to try a new palette. Each mapping keeps its original group
@@ -54,13 +57,68 @@ changes, pause during live capture, and return when frozen again. Opening a new
image or choosing a new capture source clears them. image or choosing a new capture source clears them.
Mappings apply before the preview adjustments or vision filter. Source preview, Mappings apply before the preview adjustments or vision filter. Source preview,
analysis, copied pixel values, and **Save crop PNG** still use the original. copied pixel values, and **Save crop PNG** still use the original. Color analysis
and the vision check use the edited selection.
**Save mapped crop PNG** exports the crop with replacements, preserving alpha **Save mapped crop PNG** exports the crop with replacements, preserving alpha
and excluding preview adjustments and filters. and excluding preview adjustments and filters.
## Automatic harmony
Freeze a capture or import an image, open **Harmonize**, and enable **Preview
harmony**. Choose **Monochromatic**, **Analogous**, **Complementary**,
**Split-Complementary**, or **Triadic**. The app fits that rule to the crop automatically, then moves its hue families toward the
chosen harmony while preserving shading. **Strength** blends from the input
at 0% to the full transformation at 100%. **Rotation**, or dragging the color
wheel, changes the palette's direction. White dots on the wheel mark input hue
families; colored spokes mark target hues. The wheel shows targets at full
strength. **Auto fit** resets rotation and fits the current crop again.
**Protect neutrals** is enabled by default: gray and near-gray colors stay
unchanged, with a gradual transition into chromatic colors. **Reset harmony**
turns harmony off and restores its defaults. Disabling **Preview harmony** lets
you compare with the input (including any enabled manual mappings). Settings
pause during live capture, resume when frozen, and reset for a new image or
capture source. Changing the crop or manual mappings refits the harmony while
retaining your rotation and strength.
Harmony works in OKLCH: it holds perceptual lightness and chroma while shifting
hue, reducing chroma when needed to fit sRGB. It retains small hue variations
within a family and compresses broad families into bands around target hues.
Monochromatic instead converges to a single hue at full strength, retaining
lightness and chroma variations (with neutral protection still applied).
The offsets are 0° for monochromatic, −30°/0°/+30° for analogous, 0°/180° for
complementary, 0°/150°/210° for split-complementary, and 0°/120°/240° for triadic,
on the **OKLCH** wheel. These angles do not produce
identical color pairs to HSV or traditional artist color wheels.
The fit uses a circular hue histogram, finds hue families, and searches for the
orientation requiring the least weighted squared hue movement. Counts are
square-root weighted after hue binning so a large background has less influence
and compression-generated near-duplicates don't each get a separate vote. This
is an experimental palette heuristic: it does not recognize objects, protect
skin or gameplay indicators automatically, or guarantee that every target hue
is used. Families may merge, and family boundaries can still affect subtle hue
ramps. Start with moderate strength and compare the result in context.
The processing order is **source → manual mappings → harmony → preview filter
or adjustments**. The source preview and copied pixel values remain original; color analysis and
the vision check use the edited selection. **Save harmonized crop PNG** includes enabled mappings and harmony,
preserving transparency, without hover highlights, preview filters, or
adjustments. **Save mapped crop PNG** still saves only manual replacements;
**Save crop PNG** still saves the original.
The conversion uses [Björn Ottosson's Oklab transforms](https://bottosson.github.io/posts/oklab/).
## Color analysis and vision checks ## Color analysis and vision checks
The **Color analysis** tab has four distributions: The **Color analysis** tab uses the edited selection after active mappings,
harmony, and the selected filter or adjustments. Histograms, means, neutral
counts, distinct-color weighting, and the **Edited** palette all update together.
Merged colors count once for distinct-color weighting. Diagnostic simulation
previews and hover highlights are excluded, just as in the vision check.
**Source / map** keeps the original palette available for choosing replacements.
The tab has four distributions:
- **Hue**: HSV hue in 36 ten-degree bins; saturation below 5% counts as neutral. - **Hue**: HSV hue in 36 ten-degree bins; saturation below 5% counts as neutral.
- **Saturation**: HSV saturation, from neutral to fully saturated. - **Saturation**: HSV saturation, from neutral to fully saturated.
@@ -80,8 +138,8 @@ tritanopia, or grayscale directly to the crop preview, or select **Original**
to use the live color adjustment sliders. The first three use the to use the live color adjustment sliders. The first three use the
Machado–Oliveira–Fernandes full-severity model, applied in linear RGB and then Machado–Oliveira–Fernandes full-severity model, applied in linear RGB and then
encoded back to sRGB. Grayscale uses relative luminance and tests removal of color encoded back to sRGB. Grayscale uses relative luminance and tests removal of color
cues; it does not model all aspects of achromatopsia. Preview mode does not change cues; it does not model all aspects of achromatopsia. The selected filter updates color analysis and the vision check. Copied pixel
histograms, copied pixel values, or the exported PNG: those always use the original. values and **Save crop PNG** still use the original.
**Invert** replaces each RGB channel with its complement (255 minus the channel). **Invert** replaces each RGB channel with its complement (255 minus the channel).
With **Original** selected, adjust **Hue shift** (−180° to +180°), **Saturation** With **Original** selected, adjust **Hue shift** (−180° to +180°), **Saturation**
@@ -94,12 +152,24 @@ while another filter is selected and applied again when returning to Original.
Filters preserve alpha and always start from source pixels, so edits do not Filters preserve alpha and always start from source pixels, so edits do not
accumulate rounding errors. accumulate rounding errors.
**Vision check** automatically checks all four scenarios. It flags pairs whose **Vision check** automatically checks all four scenarios against the **edited
OKLab distance falls from at least 0.08 to at most 0.04, losing at least 50% of the selection**, after enabled group/manual mappings, harmony, and the selected
original separation. These are project-specific screening heuristics, not filter or adjustments. It updates when these edits or the crop change. Palette
validated visibility thresholds or WCAG criteria. Each scenario shows original grouping alone only organizes colors; mapping a group changes the checked pixels.
Colors that merge through editing count as one color, and opaque-pixel coverage
is recalculated from the result. Color distributions and the edited palette use
those same pixels; **Source / map** remains available for mapping input colors.
The report's **Preview** buttons add a separate simulation of those edited
pixels. They do not change the selected filter or feed simulated pixels back into
the report. Use **Return to edited preview** to remove this simulation. Temporary
palette hover highlights are also excluded from the check.
The check flags pairs whose OKLab distance falls from at least 0.08 to at most 0.04, losing at least 50% of the
separation in the edited image. These are project-specific screening heuristics, not
validated visibility thresholds or WCAG criteria. Each scenario shows edited
and simulated swatches, numeric hex values, and up to six closest candidate pairs. and simulated swatches, numeric hex values, and up to six closest candidate pairs.
The preview button opens the corresponding simulation for the current crop. The preview button opens the corresponding simulation for the edited crop.
The check is bounded to the 64 most frequent **opaque** colors and reports both The check is bounded to the 64 most frequent **opaque** colors and reports both
color and pixel coverage. Partially/fully transparent pixels have no known color and pixel coverage. Partially/fully transparent pixels have no known
@@ -156,6 +226,9 @@ repeated portal requests during/after capture. The portal regression test requir
bus and does not open desktop dialogs. Color tests cover histogram endpoints and bus and does not open desktop dialogs. Color tests cover histogram endpoints and
weighting, sRGB gamma, simulation reference colors, alpha handling, warning weighting, sRGB gamma, simulation reference colors, alpha handling, warning
positives/negatives, palette coverage, and preserving source pixels. positives/negatives, palette coverage, and preserving source pixels.
Harmony tests cover color-space reference values, gamut reduction, circular hue
fitting, neutral protection, shading, alpha, preset rendering, crop and capture
lifecycle, and exported PNG pixels.
Actual portal capture requires a running Actual portal capture requires a running
Wayland desktop and permission through KDE's picker. Wayland desktop and permission through KDE's picker.
+493
View File
@@ -0,0 +1,493 @@
//! Hue-family harmonization in OKLCH. This is a palette heuristic, not object
//! segmentation or an implementation of the Cohen-Or harmonization paper.
use super::vision::{encode, linear_rgb, oklab};
use std::collections::HashMap;
const BINS: usize = 72;
const BIN_WIDTH: f64 = 360.0 / BINS as f64;
const NEUTRAL_CHROMA: f64 = 0.025;
const BAND_WIDTH: f64 = 12.0;
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum Scheme {
Monochromatic,
Analogous,
#[default]
Complementary,
SplitComplementary,
Triadic,
}
impl Scheme {
pub const ALL: [Self; 5] = [
Self::Monochromatic,
Self::Analogous,
Self::Complementary,
Self::SplitComplementary,
Self::Triadic,
];
pub fn label(self) -> &'static str {
match self {
Self::Monochromatic => "Monochromatic",
Self::Analogous => "Analogous",
Self::Complementary => "Complementary",
Self::SplitComplementary => "Split-Complementary",
Self::Triadic => "Triadic",
}
}
pub fn description(self) -> &'static str {
match self {
Self::Monochromatic => "One hue, with variations in lightness and color intensity.",
Self::Analogous => "Nearby hue families for a more unified palette.",
Self::Complementary => "Two opposing hue regions for a main color and an accent.",
Self::SplitComplementary => "A main hue and two hues on either side of its opposite.",
Self::Triadic => "Three evenly spaced hue regions for a varied palette.",
}
}
pub fn offsets(self) -> &'static [f64] {
match self {
Self::Monochromatic => &[0.0],
Self::Analogous => &[-30.0, 0.0, 30.0],
Self::Complementary => &[0.0, 180.0],
Self::SplitComplementary => &[0.0, 150.0, 210.0],
Self::Triadic => &[0.0, 120.0, 240.0],
}
}
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Harmony {
pub enabled: bool,
pub scheme: Scheme,
pub strength: f64,
pub rotation: f64,
pub protect_neutrals: bool,
}
impl Default for Harmony {
fn default() -> Self {
Self {
enabled: false,
scheme: Scheme::default(),
strength: 0.8,
rotation: 0.0,
protect_neutrals: true,
}
}
}
#[derive(Debug)]
pub struct Family {
pub hue: f64,
weight: f64,
width: f64,
target: f64,
}
#[derive(Default, Debug)]
pub struct Fit {
pub orientation: f64,
pub families: Vec<Family>,
}
/// Signed shortest arc, including wrapping between 359° and 0°.
pub fn angle_delta(from: f64, to: f64) -> f64 {
(to - from + 180.0).rem_euclid(360.0) - 180.0
}
fn lch(rgb: [u8; 3]) -> [f64; 3] {
let [l, a, b] = oklab(linear_rgb(rgb));
[l, a.hypot(b), b.atan2(a).to_degrees().rem_euclid(360.0)]
}
// Public-domain inverse transform by Björn Ottosson:
// https://bottosson.github.io/posts/oklab/
fn to_linear([l, c, h]: [f64; 3]) -> [f64; 3] {
let a = c * h.to_radians().cos();
let b = c * h.to_radians().sin();
let ll = (l + 0.3963377774 * a + 0.2158037573 * b).powi(3);
let m = (l - 0.1055613458 * a - 0.0638541728 * b).powi(3);
let s = (l - 0.0894841775 * a - 1.2914855480 * b).powi(3);
[
4.0767416621 * ll - 3.3077115913 * m + 0.2309699292 * s,
-1.2684380046 * ll + 2.6097574011 * m - 0.3413193965 * s,
-0.0041960863 * ll - 0.7034186147 * m + 1.7076147010 * s,
]
}
fn in_gamut(rgb: [f64; 3]) -> bool {
rgb.iter().all(|v| (-1e-7..=1.0 + 1e-7).contains(v))
}
/// Reduce chroma only when needed to keep lightness and hue in sRGB gamut.
pub fn from_lch([l, c, h]: [f64; 3]) -> [u8; 3] {
let rgb = to_linear([l, c, h]);
if in_gamut(rgb) {
return rgb.map(encode);
}
let (mut low, mut high) = (0.0, c);
for _ in 0..20 {
let mid = (low + high) / 2.0;
if in_gamut(to_linear([l, mid, h])) {
low = mid;
} else {
high = mid;
}
}
to_linear([l, low, h]).map(encode)
}
impl Fit {
pub fn new(colors: impl IntoIterator<Item = ([u8; 3], u64)>, settings: Harmony) -> Self {
let mut counts = [0.0; BINS];
let mut chroma = [0.0; BINS];
for (rgb, count) in colors {
let [_, c, h] = lch(rgb);
if c < 1e-5 || (settings.protect_neutrals && c <= NEUTRAL_CHROMA) {
continue;
}
let bin = (h / BIN_WIDTH).floor() as usize % BINS;
counts[bin] += count as f64;
chroma[bin] += c * count as f64;
}
// Count compression happens after hue binning, so compression-generated
// RGB duplicates do not gain extra votes. Small accents still contribute.
let weights: [f64; BINS] = std::array::from_fn(|i| {
if counts[i] == 0.0 {
0.0
} else {
counts[i].sqrt() * chroma[i] / counts[i]
}
});
let smooth: [f64; BINS] = std::array::from_fn(|i| {
weights[(i + BINS - 1) % BINS] + 2.0 * weights[i] + weights[(i + 1) % BINS]
});
let mut peaks: Vec<_> = (0..BINS)
.filter(|&i| {
smooth[i] > 0.0
&& smooth[i] >= smooth[(i + BINS - 1) % BINS]
&& smooth[i] >= smooth[(i + 1) % BINS]
})
.collect();
peaks.sort_by(|&a, &b| smooth[b].total_cmp(&smooth[a]).then(a.cmp(&b)));
let mut centers: Vec<f64> = Vec::new();
for bin in peaks {
let hue = (bin as f64 + 0.5) * BIN_WIDTH;
if centers
.iter()
.all(|&old| angle_delta(old, hue).abs() >= 30.0)
{
centers.push(hue);
}
}
if centers.is_empty() {
return Self::default();
}
// Refine each peak to a circular mean of its hue neighborhood.
let mut sums = vec![[0.0; 3]; centers.len()];
for (i, &weight) in weights.iter().enumerate() {
if weight == 0.0 {
continue;
}
let hue = (i as f64 + 0.5) * BIN_WIDTH;
let index = nearest(&centers, hue);
sums[index][0] += weight * hue.to_radians().cos();
sums[index][1] += weight * hue.to_radians().sin();
sums[index][2] += weight;
}
let mut families: Vec<_> = sums
.into_iter()
.filter(|s| s[2] > 0.0)
.map(|[x, y, weight]| Family {
hue: y.atan2(x).to_degrees().rem_euclid(360.0),
weight,
width: BIN_WIDTH,
target: 0.0,
})
.collect();
families.sort_by(|a, b| a.hue.total_cmp(&b.hue));
let centers: Vec<_> = families.iter().map(|f| f.hue).collect();
for (i, &weight) in weights.iter().enumerate() {
if weight == 0.0 {
continue;
}
let hue = (i as f64 + 0.5) * BIN_WIDTH;
let family = &mut families[nearest(&centers, hue)];
family.width = family
.width
.max(angle_delta(family.hue, hue).abs() + BIN_WIDTH / 2.0);
}
let orientation = (0..360)
.min_by(|&a, &b| {
fit_cost(&families, settings.scheme, a as f64)
.total_cmp(&fit_cost(&families, settings.scheme, b as f64))
.then(a.cmp(&b))
})
.unwrap() as f64;
let targets: Vec<_> = settings
.scheme
.offsets()
.iter()
.map(|h| h + orientation)
.collect();
for family in &mut families {
family.target = targets[nearest(&targets, family.hue)].rem_euclid(360.0);
}
Self {
orientation,
families,
}
}
fn apply(&self, rgb: [u8; 3], settings: Harmony) -> [u8; 3] {
if !settings.enabled || settings.strength == 0.0 || self.families.is_empty() {
return rgb;
}
let [l, c, hue] = lch(rgb);
if c < 1e-5 || (settings.protect_neutrals && c <= NEUTRAL_CHROMA) {
return rgb;
}
let family = self
.families
.iter()
.min_by(|a, b| {
angle_delta(a.hue, hue)
.abs()
.total_cmp(&angle_delta(b.hue, hue).abs())
})
.unwrap();
let offset = angle_delta(family.hue, hue);
let scale = if settings.scheme == Scheme::Monochromatic {
0.0
} else {
(BAND_WIDTH / family.width).min(1.0)
};
let shift =
angle_delta(family.hue, family.target) + settings.rotation + offset * (scale - 1.0);
// Fade in above the protected neutral range to avoid a sudden tint edge.
let protection = if settings.protect_neutrals {
((c - NEUTRAL_CHROMA) / NEUTRAL_CHROMA).clamp(0.0, 1.0)
} else {
1.0
};
let strength = settings.strength.clamp(0.0, 1.0) * protection;
if shift.abs() < 1e-8 {
return rgb;
}
from_lch([l, c, hue + shift * strength])
}
pub fn apply_rgba(&self, rgba: &[u8], settings: Harmony) -> Vec<u8> {
let mut output = rgba.to_vec();
if !settings.enabled || settings.strength == 0.0 || self.families.is_empty() {
return output;
}
let mut cache = HashMap::new();
for pixel in output.as_chunks_mut::<4>().0 {
if pixel[3] == 0 {
continue;
}
let rgb = [pixel[0], pixel[1], pixel[2]];
pixel[..3].copy_from_slice(
cache
.entry(rgb)
.or_insert_with(|| self.apply(rgb, settings)),
);
}
output
}
}
fn nearest(hues: &[f64], hue: f64) -> usize {
(0..hues.len())
.min_by(|&a, &b| {
angle_delta(hue, hues[a])
.abs()
.total_cmp(&angle_delta(hue, hues[b]).abs())
.then(a.cmp(&b))
})
.unwrap()
}
fn fit_cost(families: &[Family], scheme: Scheme, orientation: f64) -> f64 {
families
.iter()
.map(|family| {
let distance = scheme
.offsets()
.iter()
.map(|offset| angle_delta(family.hue, orientation + offset).powi(2))
.min_by(f64::total_cmp)
.unwrap();
family.weight * distance
})
.sum()
}
#[cfg(test)]
mod tests {
use super::*;
fn enabled() -> Harmony {
Harmony {
enabled: true,
strength: 1.0,
..Default::default()
}
}
#[test]
fn oklch_reference_roundtrips_and_gamut_reduction_preserve_lightness() {
let [l, c, h] = lch([255, 0, 0]);
assert!((l - 0.627955).abs() < 1e-5);
assert!((c - 0.257683).abs() < 1e-5);
assert!((h - 29.234).abs() < 0.01);
for rgb in [
[255, 0, 0],
[0, 255, 0],
[0, 0, 255],
[255; 3],
[0; 3],
[92, 113, 145],
] {
assert_eq!(from_lch(lch(rgb)), rgb);
}
for hue in (0..360).step_by(15) {
let mapped = lch(from_lch([0.6, 0.4, hue as f64]));
assert!((mapped[0] - 0.6).abs() < 0.003);
assert!(mapped[1] < 0.4);
assert!(angle_delta(hue as f64, mapped[2]).abs() < 1.5);
}
}
#[test]
fn disabled_zero_strength_neutrals_and_alpha_are_preserved() {
let mut settings = enabled();
settings.rotation = 90.0;
let near_gray = from_lch([0.6, 0.015, 40.0]);
let pixels = [
255,
0,
0,
128,
100,
100,
100,
255,
near_gray[0],
near_gray[1],
near_gray[2],
255,
0,
0,
255,
0,
];
let fit = Fit::new(
[([255, 0, 0], 1), ([100; 3], 100), (near_gray, 100)],
settings,
);
let result = fit.apply_rgba(&pixels, settings);
assert_ne!(&result[..3], &pixels[..3]);
assert_eq!(result[3], 128);
assert_eq!(&result[4..], &pixels[4..]);
assert_eq!(fit.apply_rgba(&pixels, Harmony::default()), pixels);
settings.strength = 0.0;
assert_eq!(fit.apply_rgba(&pixels, settings), pixels);
assert!(Fit::new([([128; 3], 100)], enabled()).families.is_empty());
assert!(Fit::new([], enabled()).families.is_empty());
}
#[test]
fn shading_and_local_hue_variation_survive_harmonization() {
let ramp: Vec<_> = [(0.35, 37.0), (0.55, 40.0), (0.75, 43.0)]
.into_iter()
.map(|(l, h)| from_lch([l, 0.07, h]))
.collect();
let settings = Harmony {
rotation: 95.0,
..enabled()
};
let fit = Fit::new(ramp.iter().map(|&rgb| (rgb, 10)), settings);
assert_eq!(fit.families.len(), 1);
let output: Vec<_> = ramp
.iter()
.map(|&rgb| lch(fit.apply(rgb, settings)))
.collect();
for (&rgb, result) in ramp.iter().zip(&output) {
assert!((lch(rgb)[0] - result[0]).abs() < 0.004);
assert!((lch(rgb)[1] - result[1]).abs() < 0.004);
}
assert!(output[0][0] < output[1][0] && output[1][0] < output[2][0]);
assert!(angle_delta(output[0][2], output[2][2]) > 2.0);
assert!(angle_delta(lch(ramp[1])[2], output[1][2]).abs() > 80.0);
let mid = lch(fit.apply(
ramp[1],
Harmony {
strength: 0.5,
..settings
},
));
let arc = angle_delta(lch(ramp[1])[2], output[1][2]);
assert!((angle_delta(lch(ramp[1])[2], mid[2]) - arc / 2.0).abs() < 2.0);
}
#[test]
fn monochromatic_converges_to_one_hue_without_flattening_shading() {
let settings = Harmony {
scheme: Scheme::Monochromatic,
rotation: 35.0,
..enabled()
};
let colors = [
from_lch([0.35, 0.07, 25.0]),
from_lch([0.55, 0.1, 145.0]),
from_lch([0.75, 0.08, 270.0]),
];
let fit = Fit::new(colors.into_iter().map(|rgb| (rgb, 10)), settings);
for rgb in colors {
let output = lch(fit.apply(rgb, settings));
assert!(angle_delta(fit.orientation + settings.rotation, output[2]).abs() < 2.0);
assert!((lch(rgb)[0] - output[0]).abs() < 0.004);
}
}
#[test]
fn circular_families_and_fitting_are_deterministic() {
assert_eq!(angle_delta(359.0, 1.0), 2.0);
let colors = [
(from_lch([0.6, 0.12, 359.0]), 10),
(from_lch([0.6, 0.12, 1.0]), 10),
];
let fit = Fit::new(colors, enabled());
assert_eq!(fit.families.len(), 1);
assert!(angle_delta(0.0, fit.families[0].hue).abs() < 3.0);
for scheme in Scheme::ALL {
let settings = Harmony {
scheme,
..enabled()
};
let colors = [
(from_lch([0.6, 0.1, 65.0]), 10000),
(from_lch([0.6, 0.1, 230.0]), 50),
(from_lch([0.6, 0.1, 305.0]), 20),
];
let fit = Fit::new(colors, settings);
let reverse = Fit::new(colors.into_iter().rev(), settings);
assert_eq!(fit.orientation, reverse.orientation);
assert!(
fit_cost(&fit.families, scheme, fit.orientation)
<= fit_cost(&fit.families, scheme, 0.0)
);
assert!(fit.families.len() >= 3, "Keep small accents represented");
for family in &fit.families {
assert!(scheme.offsets().iter().any(|offset| {
angle_delta(family.target, fit.orientation + offset).abs() < 1e-8
}));
}
}
}
}
+7 -4
View File
@@ -122,12 +122,15 @@ impl ColorMappings {
self.0.last_mut().unwrap().target = target; self.0.last_mut().unwrap().target = target;
} }
pub fn apply_rgba(&self, rgba: &[u8]) -> Vec<u8> { pub fn lookup(&self) -> HashMap<[u8; 3], [u8; 3]> {
let lookup: HashMap<_, _> = self self.0
.0
.iter() .iter()
.flat_map(|entry| entry.members.iter().map(move |&rgb| (rgb, entry.target))) .flat_map(|entry| entry.members.iter().map(move |&rgb| (rgb, entry.target)))
.collect(); .collect()
}
pub fn apply_rgba(&self, rgba: &[u8]) -> Vec<u8> {
let lookup = self.lookup();
let mut output = rgba.to_vec(); let mut output = rgba.to_vec();
for pixel in output.as_chunks_mut::<4>().0 { for pixel in output.as_chunks_mut::<4>().0 {
if pixel[3] != 0 if pixel[3] != 0
+1
View File
@@ -1,6 +1,7 @@
//! Analysis always uses source pixels, never the scaled preview. //! Analysis always uses source pixels, never the scaled preview.
use std::collections::HashMap; use std::collections::HashMap;
pub mod adjustments; pub mod adjustments;
pub mod harmony;
pub mod mapping; pub mod mapping;
pub mod vision; pub mod vision;
pub const LEVEL_BINS: usize = 20; pub const LEVEL_BINS: usize = 20;
+5 -4
View File
@@ -86,7 +86,7 @@ fn decode(value: u8) -> f64 {
} }
} }
fn encode(value: f64) -> u8 { pub(super) fn encode(value: f64) -> u8 {
let value = value.clamp(0.0, 1.0); let value = value.clamp(0.0, 1.0);
let value = if value <= 0.0031308 { let value = if value <= 0.0031308 {
value * 12.92 value * 12.92
@@ -110,7 +110,7 @@ pub fn luminance(rgb: [u8; 3]) -> f64 {
} }
// OKLab reference transform: https://bottosson.github.io/posts/oklab/ // OKLab reference transform: https://bottosson.github.io/posts/oklab/
fn oklab([r, g, b]: [f64; 3]) -> [f64; 3] { pub(super) fn oklab([r, g, b]: [f64; 3]) -> [f64; 3] {
let l = (0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b).cbrt(); let l = (0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b).cbrt();
let m = (0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b).cbrt(); let m = (0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b).cbrt();
let s = (0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b).cbrt(); let s = (0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b).cbrt();
@@ -146,7 +146,7 @@ pub fn simulate_rgba(rgba: &[u8], mode: Vision) -> Vec<u8> {
} }
pub const MAX_COLORS: usize = 64; pub const MAX_COLORS: usize = 64;
#[derive(Debug)] #[derive(Clone, Debug, PartialEq)]
pub struct PairWarning { pub struct PairWarning {
pub a: [u8; 3], pub a: [u8; 3],
pub b: [u8; 3], pub b: [u8; 3],
@@ -154,12 +154,13 @@ pub struct PairWarning {
pub after: f64, pub after: f64,
} }
#[derive(Clone, Debug, PartialEq)]
pub struct Scenario { pub struct Scenario {
pub mode: Vision, pub mode: Vision,
pub pairs: Vec<PairWarning>, pub pairs: Vec<PairWarning>,
} }
#[derive(Default)] #[derive(Clone, Debug, Default, PartialEq)]
pub struct Report { pub struct Report {
pub scenarios: Vec<Scenario>, pub scenarios: Vec<Scenario>,
pub colors_checked: usize, pub colors_checked: usize,
+586 -45
View File
@@ -3,6 +3,7 @@ use crate::{
analysis::{ analysis::{
self, Analysis, Region, self, Analysis, Region,
adjustments::Adjustments, adjustments::Adjustments,
harmony::{self, Harmony, Scheme},
mapping::{ColorGroup, ColorMappings, group_palette}, mapping::{ColorGroup, ColorMappings, group_palette},
vision::{self, Vision}, vision::{self, Vision},
}, },
@@ -13,6 +14,20 @@ use crate::{
use eframe::egui::{self, Color32}; use eframe::egui::{self, Color32};
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
#[derive(Clone, Copy, PartialEq, Eq)]
enum Sidebar {
Colors,
Vision,
Harmony,
}
#[derive(Clone, Copy)]
enum Export {
Original,
Mapped,
Harmonized,
}
pub struct WhosHueApp { pub struct WhosHueApp {
capture: Option<SharedCapture>, capture: Option<SharedCapture>,
config: Config, config: Config,
@@ -22,6 +37,10 @@ pub struct WhosHueApp {
crop_texture: Option<egui::TextureHandle>, crop_texture: Option<egui::TextureHandle>,
simulated_texture: Option<egui::TextureHandle>, simulated_texture: Option<egui::TextureHandle>,
vision_mode: Vision, vision_mode: Vision,
vision_preview: Vision,
edited_analysis: Option<Analysis>,
edited_palette_groups: Vec<ColorGroup>,
palette_source: bool,
adjustments: Adjustments, adjustments: Adjustments,
mappings: ColorMappings, mappings: ColorMappings,
group_colors: bool, group_colors: bool,
@@ -30,7 +49,9 @@ pub struct WhosHueApp {
hovered_group: Option<usize>, hovered_group: Option<usize>,
highlight_texture: Option<egui::TextureHandle>, highlight_texture: Option<egui::TextureHandle>,
mappings_enabled: bool, mappings_enabled: bool,
vision_panel: bool, sidebar_tab: Sidebar,
harmony: Harmony,
harmony_fit: harmony::Fit,
channel: ui::Channel, channel: ui::Channel,
region: Option<Region>, region: Option<Region>,
analysis: Analysis, analysis: Analysis,
@@ -62,6 +83,10 @@ impl WhosHueApp {
crop_texture: None, crop_texture: None,
simulated_texture: None, simulated_texture: None,
vision_mode: Vision::Original, vision_mode: Vision::Original,
vision_preview: Vision::Original,
edited_analysis: None,
edited_palette_groups: Vec::new(),
palette_source: false,
adjustments: Adjustments::default(), adjustments: Adjustments::default(),
mappings: ColorMappings::default(), mappings: ColorMappings::default(),
group_colors: true, group_colors: true,
@@ -70,7 +95,9 @@ impl WhosHueApp {
hovered_group: None, hovered_group: None,
highlight_texture: None, highlight_texture: None,
mappings_enabled: true, mappings_enabled: true,
vision_panel: false, sidebar_tab: Sidebar::Colors,
harmony: Harmony::default(),
harmony_fit: harmony::Fit::default(),
channel: ui::Channel::Hue, channel: ui::Channel::Hue,
region: None, region: None,
analysis: Analysis::default(), analysis: Analysis::default(),
@@ -95,6 +122,8 @@ impl WhosHueApp {
// A new selection always opens KDE's picker; never silently restore it. // A new selection always opens KDE's picker; never silently restore it.
self.capture = Some(capture::spawn(None)); self.capture = Some(capture::spawn(None));
self.mappings = ColorMappings::default(); self.mappings = ColorMappings::default();
self.harmony = Harmony::default();
self.harmony_fit = harmony::Fit::default();
self.mappings_enabled = true; self.mappings_enabled = true;
self.frame = None; self.frame = None;
self.texture = None; self.texture = None;
@@ -102,6 +131,9 @@ impl WhosHueApp {
self.simulated_texture = None; self.simulated_texture = None;
self.region = None; self.region = None;
self.analysis = Analysis::default(); self.analysis = Analysis::default();
self.edited_analysis = None;
self.edited_palette_groups.clear();
self.vision_preview = Vision::Original;
self.rebuild_groups(); self.rebuild_groups();
self.frozen = false; self.frozen = false;
self.drag_origin = None; self.drag_origin = None;
@@ -160,6 +192,7 @@ impl WhosHueApp {
region.height, region.height,
&pixels, &pixels,
); );
self.refit_harmony();
self.refresh_simulation(ctx); self.refresh_simulation(ctx);
} }
} }
@@ -177,6 +210,36 @@ impl WhosHueApp {
self.highlight_texture = None; self.highlight_texture = None;
} }
fn current_analysis(&self) -> &Analysis {
self.edited_analysis.as_ref().unwrap_or(&self.analysis)
}
fn displayed_groups(&self) -> &[ColorGroup] {
if self.palette_source || self.edited_analysis.is_none() {
&self.palette_groups
} else {
&self.edited_palette_groups
}
}
fn rebuild_edited_groups(&mut self) {
self.edited_palette_groups =
self.edited_analysis
.as_ref()
.map_or_else(Vec::new, |analysis| {
group_palette(
&analysis.palette,
if self.group_colors {
self.group_tolerance
} else {
0
},
)
});
self.hovered_group = None;
self.highlight_texture = None;
}
fn highlight_group(&mut self, ctx: &egui::Context, index: Option<usize>) { fn highlight_group(&mut self, ctx: &egui::Context, index: Option<usize>) {
if self.hovered_group == index { if self.hovered_group == index {
return; return;
@@ -187,7 +250,15 @@ impl WhosHueApp {
&& let Some(frame) = &self.frame && let Some(frame) = &self.frame
{ {
let region = self.region.unwrap_or_else(|| Region::full(frame)); let region = self.region.unwrap_or_else(|| Region::full(frame));
let pixels = self.palette_groups[index].highlight_rgba(&region.crop(frame)); let crop = if self.palette_source {
region.crop(frame)
} else {
self.edited_crop(region, frame)
};
let Some(group) = self.displayed_groups().get(index) else {
return;
};
let pixels = group.highlight_rgba(&crop);
Self::texture( Self::texture(
ctx, ctx,
&mut self.highlight_texture, &mut self.highlight_texture,
@@ -216,22 +287,81 @@ impl WhosHueApp {
} }
} }
fn refresh_simulation(&mut self, ctx: &egui::Context) { fn harmony_active(&self) -> bool {
if self.vision_mode == Vision::Original self.can_map() && self.harmony.enabled
&& self.adjustments == Adjustments::default() }
&& !self.mappings_active()
{ fn refit_harmony(&mut self) {
self.simulated_texture = None; if !self.harmony_active() {
return; return;
} }
if let Some(frame) = &self.frame { let lookup = if self.mappings_active() {
let region = self.region.unwrap_or_else(|| Region::full(frame)); self.mappings.lookup()
} else {
Default::default()
};
self.harmony_fit = harmony::Fit::new(
self.analysis.palette.iter().map(|swatch| {
(
*lookup.get(&swatch.rgb).unwrap_or(&swatch.rgb),
swatch.count,
)
}),
self.harmony,
);
}
fn harmonized_crop(&self, region: Region, frame: &Frame) -> Vec<u8> {
let crop = self.mapped_crop(region, frame); let crop = self.mapped_crop(region, frame);
let pixels = if self.vision_mode == Vision::Original { if self.harmony_active() {
self.harmony_fit.apply_rgba(&crop, self.harmony)
} else {
crop
}
}
fn export_pixels(&self, region: Region, frame: &Frame, kind: Export) -> Vec<u8> {
match kind {
Export::Original => region.crop(frame),
Export::Mapped => self.mapped_crop(region, frame),
Export::Harmonized => self.harmonized_crop(region, frame),
}
}
/// Pixels being checked, before the report's optional simulation overlay.
fn edited_crop(&self, region: Region, frame: &Frame) -> Vec<u8> {
let crop = self.harmonized_crop(region, frame);
if self.vision_mode == Vision::Original {
self.adjustments.apply_rgba(&crop) self.adjustments.apply_rgba(&crop)
} else { } else {
vision::simulate_rgba(&crop, self.vision_mode) vision::simulate_rgba(&crop, self.vision_mode)
}
}
fn refresh_simulation(&mut self, ctx: &egui::Context) {
let Some(frame) = &self.frame else {
self.edited_analysis = None;
self.edited_palette_groups.clear();
self.simulated_texture = None;
return;
}; };
let region = self.region.unwrap_or_else(|| Region::full(frame));
let unchanged = self.vision_mode == Vision::Original
&& self.adjustments == Adjustments::default()
&& !self.mappings_active()
&& !self.harmony_active();
if unchanged && self.vision_preview == Vision::Original {
self.edited_analysis = None;
self.edited_palette_groups.clear();
self.hovered_group = None;
self.highlight_texture = None;
self.simulated_texture = None;
return;
}
let pixels = self.edited_crop(region, frame);
self.edited_analysis = (!unchanged).then(|| analysis::analyze(&pixels));
self.rebuild_edited_groups();
let pixels = vision::simulate_rgba(&pixels, self.vision_preview);
Self::texture( Self::texture(
ctx, ctx,
&mut self.simulated_texture, &mut self.simulated_texture,
@@ -241,7 +371,6 @@ impl WhosHueApp {
&pixels, &pixels,
); );
} }
}
fn load_image(&mut self, ctx: &egui::Context, path: &std::path::Path) { fn load_image(&mut self, ctx: &egui::Context, path: &std::path::Path) {
match image::open(path) { match image::open(path) {
@@ -252,10 +381,13 @@ impl WhosHueApp {
return; return;
} }
self.stop(); self.stop();
self.vision_preview = Vision::Original;
self.region = None; self.region = None;
self.drag_origin = None; self.drag_origin = None;
self.frozen = true; self.frozen = true;
self.mappings = ColorMappings::default(); self.mappings = ColorMappings::default();
self.harmony = Harmony::default();
self.harmony_fit = harmony::Fit::default();
self.mappings_enabled = true; self.mappings_enabled = true;
self.set_frame( self.set_frame(
ctx, ctx,
@@ -286,7 +418,7 @@ impl WhosHueApp {
}); });
} }
fn export(&mut self, mapped: bool) { fn export(&mut self, kind: Export) {
if let Some(frame) = &self.frame { if let Some(frame) = &self.frame {
let r = self.region.unwrap_or_else(|| Region::full(frame)); let r = self.region.unwrap_or_else(|| Region::full(frame));
// Unique output preserves earlier captures. // Unique output preserves earlier captures.
@@ -297,11 +429,7 @@ impl WhosHueApp {
let path = std::env::temp_dir().join(format!("whoshue-{stamp}.png")); let path = std::env::temp_dir().join(format!("whoshue-{stamp}.png"));
self.message = match image::save_buffer( self.message = match image::save_buffer(
&path, &path,
&if mapped { &self.export_pixels(r, frame, kind),
self.mapped_crop(r, frame)
} else {
r.crop(frame)
},
r.width, r.width,
r.height, r.height,
image::ColorType::Rgba8, image::ColorType::Rgba8,
@@ -469,6 +597,18 @@ impl WhosHueApp {
if previous_mode != self.vision_mode || previous_adjustments != self.adjustments { if previous_mode != self.vision_mode || previous_adjustments != self.adjustments {
self.refresh_simulation(ui.ctx()); self.refresh_simulation(ui.ctx());
} }
if self.vision_preview != Vision::Original {
ui.horizontal_wrapped(|ui| {
ui.label(format!(
"Vision-check preview: {}",
self.vision_preview.label()
));
if ui.button("Return to edited preview").clicked() {
self.vision_preview = Vision::Original;
self.refresh_simulation(ui.ctx());
}
});
}
// Whole-image reset above may have changed the crop this frame. // Whole-image reset above may have changed the crop this frame.
let region = self.region.unwrap_or(Region { let region = self.region.unwrap_or(Region {
x: 0, x: 0,
@@ -489,9 +629,11 @@ impl WhosHueApp {
}, },
); );
ui.small(if self.highlight_texture.is_some() { ui.small(if self.highlight_texture.is_some() {
"Highlighted group · matching pixels shown in magenta" "Highlighted palette group · matching pixels shown in magenta"
} else if self.harmony_active() {
"Harmonized preview · analysis uses edited colors · pixel inspection uses original colors"
} else if self.mappings_active() { } else if self.mappings_active() {
"Mapped preview · pixel inspection and analysis use original colors" "Mapped preview · analysis uses edited colors · pixel inspection uses original colors"
} else { } else {
"Pixel values and Save crop PNG use original colors" "Pixel values and Save crop PNG use original colors"
}); });
@@ -560,7 +702,7 @@ impl WhosHueApp {
changed = true; changed = true;
} }
}); });
ui.small("Choose Map beside a palette color or group."); ui.small("Choose Source / map in the palette to map a color or group.");
let mut remove = None; let mut remove = None;
egui::ScrollArea::vertical() egui::ScrollArea::vertical()
.id_salt("mappings") .id_salt("mappings")
@@ -602,46 +744,137 @@ impl WhosHueApp {
) )
.clicked() .clicked()
{ {
self.export(true); self.export(Export::Mapped);
} }
}); });
if changed { if changed {
self.refit_harmony();
self.refresh_simulation(ui.ctx()); self.refresh_simulation(ui.ctx());
} }
ui.separator(); ui.separator();
} }
fn sidebar(&mut self, ui: &mut egui::Ui) { fn harmony_controls(&mut self, ui: &mut egui::Ui) {
self.mapping_controls(ui); ui.heading("Harmonize the scene");
ui.label("Bring hue families together while keeping their shading.");
if !self.can_map() {
ui.small("Freeze the capture or open an image to harmonize its colors.");
}
let old = self.harmony;
let mut refit = false;
ui.add_enabled_ui(self.can_map(), |ui| {
ui.checkbox(&mut self.harmony.enabled, "Preview harmony")
.on_hover_text("Turn off to compare with the scene before harmonization.");
ui.horizontal_wrapped(|ui| {
for scheme in Scheme::ALL {
ui.selectable_value(&mut self.harmony.scheme, scheme, scheme.label());
}
});
ui.small(self.harmony.scheme.description());
if old.scheme != self.harmony.scheme {
self.harmony.rotation = 0.0;
self.harmony.enabled = true;
}
ui.add(egui::Slider::new(&mut self.harmony.strength, 0.0..=1.0).text("Strength").custom_formatter(|n, _| format!("{:.0}%", n * 100.0)))
.on_hover_text("Zero preserves the input; full strength moves hue families into the selected harmony.");
ui.add(egui::Slider::new(&mut self.harmony.rotation, -180.0..=180.0).text("Rotation").suffix("°"));
ui.checkbox(&mut self.harmony.protect_neutrals, "Protect neutrals")
.on_hover_text("Keep near-gray pixels unchanged and gently transition to colored pixels.");
ui.horizontal(|ui| { ui.horizontal(|ui| {
ui.selectable_value(&mut self.vision_panel, false, "Color analysis"); if ui.button("Auto fit").on_hover_text("Fit the current crop with as little hue movement as possible.").clicked() {
self.harmony.enabled = true;
self.harmony.rotation = 0.0;
refit = true;
}
if ui.button("Reset harmony").clicked() {
self.harmony = Harmony::default();
}
});
});
if refit
|| old.scheme != self.harmony.scheme
|| old.protect_neutrals != self.harmony.protect_neutrals
|| (!old.enabled && self.harmony.enabled)
{
self.refit_harmony();
}
ui.add_enabled_ui(self.can_map(), |ui| {
ui::harmony_wheel(ui, &mut self.harmony, &self.harmony_fit);
});
if old != self.harmony || refit {
self.refresh_simulation(ui.ctx());
}
if self.harmony.enabled && self.can_map() && self.harmony_fit.families.is_empty() {
ui.label("No colored hue families to harmonize in this selection.");
}
ui.small("Drag the wheel to rotate. White dots show input hue families; spokes show the harmony targets.");
ui.small("Lightness is preserved as closely as possible. Very vivid colors may become less saturated to fit the display.");
if self.mappings_active() {
ui.small("Manual mappings are included before harmonization.");
}
if self.vision_mode != Vision::Original
|| self.vision_preview != Vision::Original
|| self.adjustments != Adjustments::default()
{
ui.small("A preview filter or adjustment is also selected.");
if ui.button("View harmony without filters").clicked() {
self.vision_mode = Vision::Original;
self.vision_preview = Vision::Original;
self.adjustments = Adjustments::default();
self.refresh_simulation(ui.ctx());
}
}
ui.small("Color analysis and vision check use the edited selection. Source preview and pixel inspection use original colors.");
if ui.add_enabled(self.harmony_active(), egui::Button::new("Save harmonized crop PNG"))
.on_hover_text("Includes active manual mappings and harmony, without preview filters or adjustments.").clicked() {
self.export(Export::Harmonized);
}
}
fn sidebar(&mut self, ui: &mut egui::Ui) {
ui.horizontal_wrapped(|ui| {
ui.selectable_value(&mut self.sidebar_tab, Sidebar::Colors, "Color analysis");
ui.selectable_value(&mut self.sidebar_tab, Sidebar::Harmony, "Harmonize");
let count: usize = self let count: usize = self
.analysis .current_analysis()
.vision .vision
.scenarios .scenarios
.iter() .iter()
.map(|s| s.pairs.len()) .map(|s| s.pairs.len())
.sum(); .sum();
ui.selectable_value( ui.selectable_value(
&mut self.vision_panel, &mut self.sidebar_tab,
true, Sidebar::Vision,
format!("Vision check ({count})"), format!("Vision check ({count})"),
); );
}); });
ui.separator(); ui.separator();
if self.vision_panel { if self.sidebar_tab == Sidebar::Harmony {
self.highlight_group(ui.ctx(), None); self.highlight_group(ui.ctx(), None);
let old_mode = self.vision_mode; egui::ScrollArea::vertical()
.id_salt("harmony_controls")
.show(ui, |ui| self.harmony_controls(ui));
return;
}
if self.sidebar_tab == Sidebar::Vision {
self.highlight_group(ui.ctx(), None);
let old_mode = self.vision_preview;
egui::ScrollArea::vertical() egui::ScrollArea::vertical()
.id_salt("vision_warnings") .id_salt("vision_warnings")
.show(ui, |ui| { .show(ui, |ui| {
ui::vision_report(ui, &self.analysis.vision, &mut self.vision_mode); let report = &self
.edited_analysis
.as_ref()
.unwrap_or(&self.analysis)
.vision;
ui::vision_report(ui, report, &mut self.vision_preview);
}); });
if old_mode != self.vision_mode { if old_mode != self.vision_preview {
self.refresh_simulation(ui.ctx()); self.refresh_simulation(ui.ctx());
} }
return; return;
} }
self.mapping_controls(ui);
ui.heading("Color distribution"); ui.heading("Color distribution");
ui.horizontal_wrapped(|ui| { ui.horizontal_wrapped(|ui| {
for channel in ui::Channel::ALL { for channel in ui::Channel::ALL {
@@ -652,16 +885,27 @@ impl WhosHueApp {
ui.selectable_value(&mut self.distinct, false, "By pixel count"); ui.selectable_value(&mut self.distinct, false, "By pixel count");
ui.selectable_value(&mut self.distinct, true, "By distinct color"); ui.selectable_value(&mut self.distinct, true, "By distinct color");
}); });
ui::histogram(ui, &self.analysis, self.distinct, self.channel); ui.small("Edited selection · includes mappings, harmony, and the selected filter or adjustments.");
ui::histogram(ui, self.current_analysis(), self.distinct, self.channel);
if self.channel == ui::Channel::Hue { if self.channel == ui::Channel::Hue {
ui.label(format!( ui.label(format!(
"Neutrals: {} pixels / {} colors", "Neutrals: {} pixels / {} colors",
self.analysis.neutral_pixels, self.analysis.neutral_colors self.current_analysis().neutral_pixels,
self.current_analysis().neutral_colors
)); ));
} }
ui.small(self.channel.description()); ui.small(self.channel.description());
ui.separator(); ui.separator();
ui.heading("Palette"); ui.heading("Palette");
let previous_source = self.palette_source;
ui.horizontal(|ui| {
ui.selectable_value(&mut self.palette_source, false, "Edited");
ui.selectable_value(&mut self.palette_source, true, "Source / map");
});
if previous_source != self.palette_source {
self.hovered_group = None;
self.highlight_texture = None;
}
let mut regroup = ui let mut regroup = ui
.checkbox(&mut self.group_colors, "Group similar colors") .checkbox(&mut self.group_colors, "Group similar colors")
.changed(); .changed();
@@ -671,13 +915,23 @@ impl WhosHueApp {
} }
if regroup { if regroup {
self.rebuild_groups(); self.rebuild_groups();
self.rebuild_edited_groups();
} }
ui.small("Hover a row to highlight matching pixels. Existing mappings keep their original shades."); ui.small(if self.palette_source {
"Original colors for mapping · hover to highlight source pixels."
} else {
"Resulting colors · hover to highlight edited pixels. Use Source / map to choose replacements."
});
let palette_analysis = if self.palette_source {
&self.analysis
} else {
self.current_analysis()
};
ui.label(format!( ui.label(format!(
"{} groups / {} colors · {} pixels", "{} groups / {} colors · {} pixels",
self.palette_groups.len(), self.displayed_groups().len(),
self.analysis.palette.len(), palette_analysis.palette.len(),
self.analysis.pixels palette_analysis.pixels
)); ));
ui.small("Most frequent first · click a swatch to copy hex"); ui.small("Most frequent first · click a swatch to copy hex");
let can_map = self.can_map(); let can_map = self.can_map();
@@ -687,10 +941,10 @@ impl WhosHueApp {
egui::ScrollArea::vertical().id_salt("palette").show_rows( egui::ScrollArea::vertical().id_salt("palette").show_rows(
ui, ui,
24.0, 24.0,
self.palette_groups.len(), self.displayed_groups().len(),
|ui, rows| { |ui, rows| {
for index in rows { for index in rows {
let swatch = &self.palette_groups[index]; let swatch = &self.displayed_groups()[index];
let row = ui.horizontal(|ui| { let row = ui.horizontal(|ui| {
let [r, g, b] = swatch.rgb; let [r, g, b] = swatch.rgb;
let hex = format!("#{r:02X}{g:02X}{b:02X}"); let hex = format!("#{r:02X}{g:02X}{b:02X}");
@@ -703,13 +957,13 @@ impl WhosHueApp {
} }
ui.monospace(&hex); ui.monospace(&hex);
let already_mapped = self.mappings.0.iter().any(|entry| entry.members == swatch.members); let already_mapped = self.mappings.0.iter().any(|entry| entry.members == swatch.members);
if ui.add_enabled(can_map && !already_mapped, egui::Button::new(if already_mapped { "Mapped" } else { "Map" })) if self.palette_source && ui.add_enabled(can_map && !already_mapped, egui::Button::new(if already_mapped { "Mapped" } else { "Map" }))
.on_hover_text("Map every shade in this group. Overlapping colors move to this mapping.").clicked() { .on_hover_text("Map every shade in this group. Overlapping colors move to this mapping.").clicked() {
map_color = Some(index); map_color = Some(index);
} }
ui.label(format!( ui.label(format!(
"{:.2}%", "{:.2}%",
100.0 * swatch.count as f64 / self.analysis.pixels.max(1) as f64 100.0 * swatch.count as f64 / palette_analysis.pixels.max(1) as f64
)); ));
ui.small(format!("{} shades", swatch.members.len())); ui.small(format!("{} shades", swatch.members.len()));
let [h, s, v] = analysis::hsv(swatch.rgb); let [h, s, v] = analysis::hsv(swatch.rgb);
@@ -730,6 +984,7 @@ impl WhosHueApp {
if let Some(index) = map_color { if let Some(index) = map_color {
self.mappings.insert_group(&self.palette_groups[index]); self.mappings.insert_group(&self.palette_groups[index]);
self.mappings_enabled = true; self.mappings_enabled = true;
self.refit_harmony();
self.refresh_simulation(ui.ctx()); self.refresh_simulation(ui.ctx());
} }
} }
@@ -785,17 +1040,19 @@ impl eframe::App for WhosHueApp {
.clicked() .clicked()
{ {
self.frozen = !self.frozen; self.frozen = !self.frozen;
self.refit_harmony();
self.refresh_simulation(&ctx); self.refresh_simulation(&ctx);
} }
if ui if ui
.add_enabled(has_frame, egui::Button::new("Save crop PNG")) .add_enabled(has_frame, egui::Button::new("Save crop PNG"))
.clicked() .clicked()
{ {
self.export(false); self.export(Export::Original);
} }
if self.capture.is_some() && ui.button("Stop sharing").clicked() { if self.capture.is_some() && ui.button("Stop sharing").clicked() {
self.stop(); self.stop();
self.frozen = true; self.frozen = true;
self.refit_harmony();
self.refresh_simulation(&ctx); self.refresh_simulation(&ctx);
} }
}); });
@@ -846,6 +1103,282 @@ mod tests {
} }
} }
#[test]
fn color_analysis_and_palette_follow_edits_without_changing_mapping_sources() {
let ctx = egui::Context::default();
let mut app = WhosHueApp::empty(Config::default());
app.frozen = true;
app.set_frame(
&ctx,
Frame {
seq: 1,
width: 4,
height: 1,
rgba: vec![
255, 0, 0, 255, 0, 255, 0, 128, 128, 128, 128, 255, 0, 0, 255, 0,
],
},
);
app.mappings.insert([255, 0, 0], [0, 255, 0]);
app.refresh_simulation(&ctx);
let result = app.current_analysis();
assert_eq!(result.pixels, 3);
assert_eq!(result.palette.len(), 2);
assert_eq!(result.palette[0].rgb, [0, 255, 0]);
assert_eq!(result.palette[0].count, 2);
assert_eq!(result.hue_colors.iter().sum::<u64>(), 1);
assert_eq!(result.neutral_pixels, 1);
assert_eq!(app.displayed_groups()[0].rgb, [0, 255, 0]);
app.palette_source = true;
assert!(app.displayed_groups().iter().any(|g| g.rgb == [255, 0, 0]));
assert_eq!(app.analysis.palette.len(), 3);
app.palette_source = false;
app.adjustments.saturation = -100.0;
app.refresh_simulation(&ctx);
assert_eq!(app.current_analysis().neutral_pixels, 3);
assert_eq!(app.current_analysis().neutral_colors, 2);
assert_eq!(app.current_analysis().hue_pixels.iter().sum::<u64>(), 0);
let expected = analysis::analyze(&app.edited_crop(
Region::full(app.frame.as_ref().unwrap()),
app.frame.as_ref().unwrap(),
));
for mode in Vision::CHECKS {
app.vision_preview = mode;
app.refresh_simulation(&ctx);
app.highlight_group(&ctx, Some(0));
let result = app.current_analysis();
assert_eq!(result.saturation.pixels, expected.saturation.pixels);
assert_eq!(result.saturation.colors, expected.saturation.colors);
assert_eq!(result.brightness.pixels, expected.brightness.pixels);
assert_eq!(result.brightness.colors, expected.brightness.colors);
assert_eq!(result.luminance.pixels, expected.luminance.pixels);
assert_eq!(result.luminance.colors, expected.luminance.colors);
assert_eq!(result.luminance.pixel_sum, expected.luminance.pixel_sum);
}
app.adjustments = Adjustments::default();
app.mappings_enabled = false;
app.refresh_simulation(&ctx);
assert!(app.edited_analysis.is_none());
assert_eq!(app.current_analysis().palette.len(), 3);
app.region = Some(Region {
x: 2,
y: 0,
width: 1,
height: 1,
});
app.analyze(&ctx);
assert_eq!(app.current_analysis().pixels, 1);
assert_eq!(app.current_analysis().neutral_pixels, 1);
}
#[test]
fn vision_check_tracks_mapping_toggles_and_selected_filters() {
let ctx = egui::Context::default();
let mut app = WhosHueApp::empty(Config::default());
app.frozen = true;
app.set_frame(
&ctx,
Frame {
seq: 1,
width: 3,
height: 1,
rgba: vec![255, 0, 0, 255, 0, 173, 0, 255, 0, 173, 0, 128],
},
);
let source_report = app.current_analysis().vision.clone();
assert!(source_report.scenarios.iter().any(|s| !s.pairs.is_empty()));
// Mapping the entire green family to red merges both opaque colors.
let group = app
.palette_groups
.iter()
.find(|g| g.rgb == [0, 173, 0])
.unwrap()
.clone();
app.mappings.insert_group(&group);
app.mappings.0[0].target = [255, 0, 0];
app.refresh_simulation(&ctx);
assert_eq!(app.current_analysis().vision.opaque_colors, 1);
assert_eq!(app.current_analysis().vision.opaque_pixels, 2);
assert!(
app.current_analysis()
.vision
.scenarios
.iter()
.all(|s| s.pairs.is_empty())
);
assert_eq!(app.analysis.vision, source_report);
app.mappings_enabled = false;
app.refresh_simulation(&ctx);
assert_eq!(app.current_analysis().vision, source_report);
app.adjustments.brightness = -100.0;
app.refresh_simulation(&ctx);
assert_eq!(app.current_analysis().vision.opaque_colors, 1);
assert!(
app.current_analysis()
.vision
.scenarios
.iter()
.all(|s| s.pairs.is_empty())
);
// Selected filters replace the adjustment sliders, as in the preview.
app.vision_mode = Vision::Invert;
app.refresh_simulation(&ctx);
let expected = analysis::analyze(&vision::simulate_rgba(
&app.frame.as_ref().unwrap().rgba,
Vision::Invert,
))
.vision;
assert_eq!(app.current_analysis().vision, expected);
app.vision_mode = Vision::Original;
app.adjustments = Adjustments::default();
app.mappings_enabled = true;
app.frozen = false;
app.refresh_simulation(&ctx);
assert_eq!(app.current_analysis().vision, source_report);
app.frozen = true;
app.region = Some(Region {
x: 1,
y: 0,
width: 2,
height: 1,
});
app.analyze(&ctx);
assert_eq!(app.current_analysis().vision.opaque_pixels, 1);
assert_eq!(app.current_analysis().vision.opaque_colors, 1);
}
#[test]
fn vision_check_uses_harmony_but_excludes_diagnostic_overlays() {
let ctx = egui::Context::default();
let mut app = WhosHueApp::empty(Config::default());
app.frozen = true;
app.set_frame(
&ctx,
Frame {
seq: 1,
width: 2,
height: 1,
rgba: vec![255, 0, 0, 255, 0, 173, 0, 255],
},
);
app.harmony.enabled = true;
app.harmony.rotation = 70.0;
app.refit_harmony();
app.refresh_simulation(&ctx);
let frame = app.frame.as_ref().unwrap();
let edited = app.harmony_fit.apply_rgba(&frame.rgba, app.harmony);
assert_ne!(edited, frame.rgba);
let expected = analysis::analyze(&edited).vision;
assert_eq!(app.current_analysis().vision, expected);
for mode in Vision::CHECKS {
app.vision_preview = mode;
app.refresh_simulation(&ctx);
assert_eq!(app.current_analysis().vision, expected);
assert_eq!(app.vision_mode, Vision::Original);
}
app.highlight_group(&ctx, Some(0));
assert_eq!(app.current_analysis().vision, expected);
app.vision_preview = Vision::Original;
app.harmony.enabled = false;
app.refresh_simulation(&ctx);
assert_eq!(app.current_analysis().vision, app.analysis.vision);
}
#[test]
fn harmony_preview_export_and_lifecycle_preserve_source() {
let ctx = egui::Context::default();
let mut app = WhosHueApp::empty(Config::default());
app.frozen = true;
app.set_frame(&ctx, sample(1, 3));
app.harmony.enabled = true;
app.harmony.rotation = 90.0;
app.refit_harmony();
app.refresh_simulation(&ctx);
let frame = app.frame.as_ref().unwrap();
let region = Region::full(frame);
let expected = app.harmonized_crop(region, frame);
assert_ne!(expected, frame.rgba);
assert_eq!(
app.export_pixels(region, frame, Export::Original),
sample(1, 3).rgba
);
assert_eq!(
app.export_pixels(region, frame, Export::Mapped),
sample(1, 3).rgba
);
assert_eq!(app.analysis.palette[0].rgb, [255, 0, 0]);
app.vision_mode = Vision::Invert;
app.adjustments.hue = 45.0;
app.refresh_simulation(&ctx);
app.highlight_group(&ctx, Some(0));
app.export(Export::Harmonized);
let path = std::path::PathBuf::from(app.message.strip_prefix("Saved ").unwrap());
assert_eq!(image::open(&path).unwrap().to_rgba8().into_raw(), expected);
std::fs::remove_file(path).unwrap();
app.frozen = false;
assert_eq!(
app.harmonized_crop(region, app.frame.as_ref().unwrap()),
sample(1, 3).rgba
);
app.frozen = true;
app.region = Some(Region {
x: 0,
y: 0,
width: 1,
height: 1,
});
app.analyze(&ctx);
assert_eq!(app.simulated_texture.as_ref().unwrap().size(), [1, 1]);
assert!(app.highlight_texture.is_none());
assert_eq!(app.harmony.rotation, 90.0);
app.harmony.enabled = false;
app.vision_mode = Vision::Original;
app.adjustments = Adjustments::default();
app.refresh_simulation(&ctx);
assert!(app.simulated_texture.is_none());
}
#[test]
fn harmony_fits_manual_mappings_and_renders_all_presets() {
let ctx = egui::Context::default();
let mut app = WhosHueApp::empty(Config::default());
app.frozen = true;
app.set_frame(&ctx, sample(1, 2));
app.mappings.insert([255, 0, 0], [0, 0, 255]);
app.harmony.enabled = true;
app.harmony.rotation = 70.0;
app.sidebar_tab = Sidebar::Harmony;
for scheme in Scheme::ALL {
app.harmony.scheme = scheme;
app.refit_harmony();
app.refresh_simulation(&ctx);
let frame = app.frame.as_ref().unwrap();
let mapped = app.mapped_crop(Region::full(frame), frame);
assert_eq!(mapped, [0, 0, 255, 255].repeat(4));
assert_eq!(
app.harmonized_crop(Region::full(frame), frame),
app.harmony_fit.apply_rgba(&mapped, app.harmony)
);
assert_ne!(app.harmonized_crop(Region::full(frame), frame), mapped);
for size in [egui::vec2(1200.0, 800.0), egui::vec2(700.0, 450.0)] {
let mut output = ctx.run_ui(
egui::RawInput {
screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, size)),
..Default::default()
},
|ui| {
egui::Panel::right("test_harmony_sidebar")
.default_size(370.0)
.show(ui, |ui| app.sidebar(ui));
egui::CentralPanel::default().show(ui, |ui| app.preview(ui));
},
);
assert!(!output.shapes.is_empty());
output.textures_delta.clear();
}
}
}
#[test] #[test]
fn grouped_preview_and_highlight_reset_when_recropping() { fn grouped_preview_and_highlight_reset_when_recropping() {
let ctx = egui::Context::default(); let ctx = egui::Context::default();
@@ -998,6 +1531,8 @@ mod tests {
let path = std::env::temp_dir().join(format!("whoshue picker test ü {stamp}.png")); let path = std::env::temp_dir().join(format!("whoshue picker test ü {stamp}.png"));
image::save_buffer(&path, &[0, 255, 0, 255], 1, 1, image::ColorType::Rgba8).unwrap(); image::save_buffer(&path, &[0, 255, 0, 255], 1, 1, image::ColorType::Rgba8).unwrap();
app.mappings.insert([255, 0, 0], [0, 0, 255]); app.mappings.insert([255, 0, 0], [0, 0, 255]);
app.harmony.enabled = true;
app.harmony.rotation = 75.0;
let (sender, receiver) = tokio::sync::oneshot::channel(); let (sender, receiver) = tokio::sync::oneshot::channel();
app.file_picker = Some(receiver); app.file_picker = Some(receiver);
sender.send(Ok(Some(path.clone()))).unwrap(); sender.send(Ok(Some(path.clone()))).unwrap();
@@ -1008,6 +1543,8 @@ mod tests {
assert!(app.capture.is_none()); assert!(app.capture.is_none());
assert!(app.frozen); assert!(app.frozen);
assert!(app.mappings.0.is_empty()); assert!(app.mappings.0.is_empty());
assert_eq!(app.harmony, Harmony::default());
assert!(app.harmony_fit.families.is_empty());
assert_eq!(app.frame.as_ref().unwrap().rgba, [0, 255, 0, 255]); assert_eq!(app.frame.as_ref().unwrap().rgba, [0, 255, 0, 255]);
} }
@@ -1089,7 +1626,11 @@ mod tests {
for channel in ui::Channel::ALL { for channel in ui::Channel::ALL {
app.channel = channel; app.channel = channel;
for warnings in [false, true] { for warnings in [false, true] {
app.vision_panel = warnings; app.sidebar_tab = if warnings {
Sidebar::Vision
} else {
Sidebar::Colors
};
let mut output = ctx.run_ui( let mut output = ctx.run_ui(
egui::RawInput { egui::RawInput {
screen_rect: Some(egui::Rect::from_min_size( screen_rect: Some(egui::Rect::from_min_size(
+58 -6
View File
@@ -149,6 +149,10 @@ fn swatch(ui: &mut egui::Ui, rgb: [u8; 3]) {
pub fn vision_report(ui: &mut egui::Ui, report: &Report, preview: &mut Vision) { pub fn vision_report(ui: &mut egui::Ui, report: &Report, preview: &mut Vision) {
ui.heading("Color-vision check"); ui.heading("Color-vision check");
ui.label("Potential color confusion, not an accessibility pass/fail."); ui.label("Potential color confusion, not an accessibility pass/fail.");
ui.small("Checking the edited selection: active mappings, harmony, and the selected filter or adjustments. Hover highlights and this check’s simulation previews are excluded.");
if *preview != Vision::Original && ui.button("Return to edited preview").clicked() {
*preview = Vision::Original;
}
if report.colors_checked < 2 { if report.colors_checked < 2 {
ui.label("Select at least two opaque colors to check."); ui.label("Select at least two opaque colors to check.");
return; return;
@@ -169,6 +173,12 @@ pub fn vision_report(ui: &mut egui::Ui, report: &Report, preview: &mut Vision) {
for scenario in &report.scenarios { for scenario in &report.scenarios {
ui.separator(); ui.separator();
ui.strong(scenario.mode.label()); ui.strong(scenario.mode.label());
if ui
.button(format!("Preview {}", scenario.mode.label()))
.clicked()
{
*preview = scenario.mode;
}
if scenario.pairs.is_empty() { if scenario.pairs.is_empty() {
ui.label("No strong color-merging candidates in checked colors."); ui.label("No strong color-merging candidates in checked colors.");
continue; continue;
@@ -177,12 +187,6 @@ pub fn vision_report(ui: &mut egui::Ui, report: &Report, preview: &mut Vision) {
"⚠ {} potentially confusing pairs", "⚠ {} potentially confusing pairs",
scenario.pairs.len() scenario.pairs.len()
)); ));
if ui
.button(format!("Preview {}", scenario.mode.label()))
.clicked()
{
*preview = scenario.mode;
}
for pair in scenario.pairs.iter().take(6) { for pair in scenario.pairs.iter().take(6) {
ui.horizontal(|ui| { ui.horizontal(|ui| {
swatch(ui, pair.a); swatch(ui, pair.a);
@@ -212,6 +216,54 @@ pub fn vision_report(ui: &mut egui::Ui, report: &Report, preview: &mut Vision) {
} }
} }
/// The wheel uses the same OKLCH hue coordinates as fitting and recoloring.
pub fn harmony_wheel(
ui: &mut egui::Ui,
settings: &mut crate::analysis::harmony::Harmony,
fit: &crate::analysis::harmony::Fit,
) {
use crate::analysis::harmony::{angle_delta, from_lch};
let size = ui.available_width().min(180.0);
let (rect, response) =
ui.allocate_exact_size(egui::vec2(size, size), egui::Sense::click_and_drag());
let center = rect.center();
let radius = size * 0.43;
if (response.dragged() || response.clicked())
&& let Some(pos) = response.interact_pointer_pos()
{
let offset = pos - center;
if offset.length() > radius * 0.3 {
let hue = offset.y.atan2(offset.x).to_degrees() as f64;
settings.rotation = angle_delta(fit.orientation, hue);
}
}
let point = |hue: f64, r: f32| {
center + egui::vec2(hue.to_radians().cos() as f32, hue.to_radians().sin() as f32) * r
};
let painter = ui.painter();
for hue in (0..360).step_by(3) {
let [r, g, b] = from_lch([0.72, 0.12, hue as f64]);
painter.line_segment(
[point(hue as f64, radius), point((hue + 3) as f64, radius)],
egui::Stroke::new(12.0, Color32::from_rgb(r, g, b)),
);
}
for family in &fit.families {
painter.circle_filled(point(family.hue, radius + 9.0), 3.0, Color32::WHITE);
}
for offset in settings.scheme.offsets() {
let hue = fit.orientation + settings.rotation + offset;
let [r, g, b] = from_lch([0.72, 0.12, hue]);
let color = Color32::from_rgb(r, g, b);
painter.line_segment(
[center, point(hue, radius - 10.0)],
egui::Stroke::new(2.0, color),
);
painter.circle_filled(point(hue, radius - 10.0), 5.0, color);
}
painter.circle_filled(center, 3.0, ui.visuals().text_color());
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;