diff --git a/README.md b/README.md index f4c3ffe..6f2a681 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,9 @@ cargo run --release -- /path/to/screenshot.png ## 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 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 @@ -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 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 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 @@ -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. 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 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 -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. - **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 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 -cues; it does not model all aspects of achromatopsia. Preview mode does not change -histograms, copied pixel values, or the exported PNG: those always use the original. +cues; it does not model all aspects of achromatopsia. The selected filter updates color analysis and the vision check. Copied pixel +values and **Save crop PNG** still use the original. **Invert** replaces each RGB channel with its complement (255 minus the channel). 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 accumulate rounding errors. -**Vision check** automatically checks all four scenarios. It flags pairs whose -OKLab distance falls from at least 0.08 to at most 0.04, losing at least 50% of the -original separation. These are project-specific screening heuristics, not -validated visibility thresholds or WCAG criteria. Each scenario shows original +**Vision check** automatically checks all four scenarios against the **edited +selection**, after enabled group/manual mappings, harmony, and the selected +filter or adjustments. It updates when these edits or the crop change. Palette +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. -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 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 weighting, sRGB gamma, simulation reference colors, alpha handling, warning 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 Wayland desktop and permission through KDE's picker. diff --git a/src/analysis/harmony.rs b/src/analysis/harmony.rs new file mode 100644 index 0000000..d7eda07 --- /dev/null +++ b/src/analysis/harmony.rs @@ -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, +} + +/// 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, 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 = 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(¢ers, 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(¢ers, 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 { + 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 + })); + } + } + } +} diff --git a/src/analysis/mapping.rs b/src/analysis/mapping.rs index d58caf6..919d527 100644 --- a/src/analysis/mapping.rs +++ b/src/analysis/mapping.rs @@ -122,12 +122,15 @@ impl ColorMappings { self.0.last_mut().unwrap().target = target; } - pub fn apply_rgba(&self, rgba: &[u8]) -> Vec { - let lookup: HashMap<_, _> = self - .0 + pub fn lookup(&self) -> HashMap<[u8; 3], [u8; 3]> { + self.0 .iter() .flat_map(|entry| entry.members.iter().map(move |&rgb| (rgb, entry.target))) - .collect(); + .collect() + } + + pub fn apply_rgba(&self, rgba: &[u8]) -> Vec { + let lookup = self.lookup(); let mut output = rgba.to_vec(); for pixel in output.as_chunks_mut::<4>().0 { if pixel[3] != 0 diff --git a/src/analysis/mod.rs b/src/analysis/mod.rs index f91ef1c..f343579 100644 --- a/src/analysis/mod.rs +++ b/src/analysis/mod.rs @@ -1,6 +1,7 @@ //! Analysis always uses source pixels, never the scaled preview. use std::collections::HashMap; pub mod adjustments; +pub mod harmony; pub mod mapping; pub mod vision; pub const LEVEL_BINS: usize = 20; diff --git a/src/analysis/vision.rs b/src/analysis/vision.rs index 969b78f..62e743b 100644 --- a/src/analysis/vision.rs +++ b/src/analysis/vision.rs @@ -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 = if value <= 0.0031308 { value * 12.92 @@ -110,7 +110,7 @@ pub fn luminance(rgb: [u8; 3]) -> f64 { } // 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 m = (0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * 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 { } pub const MAX_COLORS: usize = 64; -#[derive(Debug)] +#[derive(Clone, Debug, PartialEq)] pub struct PairWarning { pub a: [u8; 3], pub b: [u8; 3], @@ -154,12 +154,13 @@ pub struct PairWarning { pub after: f64, } +#[derive(Clone, Debug, PartialEq)] pub struct Scenario { pub mode: Vision, pub pairs: Vec, } -#[derive(Default)] +#[derive(Clone, Debug, Default, PartialEq)] pub struct Report { pub scenarios: Vec, pub colors_checked: usize, diff --git a/src/app.rs b/src/app.rs index 3f19aa9..88a4f2c 100644 --- a/src/app.rs +++ b/src/app.rs @@ -3,6 +3,7 @@ use crate::{ analysis::{ self, Analysis, Region, adjustments::Adjustments, + harmony::{self, Harmony, Scheme}, mapping::{ColorGroup, ColorMappings, group_palette}, vision::{self, Vision}, }, @@ -13,6 +14,20 @@ use crate::{ use eframe::egui::{self, Color32}; 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 { capture: Option, config: Config, @@ -22,6 +37,10 @@ pub struct WhosHueApp { crop_texture: Option, simulated_texture: Option, vision_mode: Vision, + vision_preview: Vision, + edited_analysis: Option, + edited_palette_groups: Vec, + palette_source: bool, adjustments: Adjustments, mappings: ColorMappings, group_colors: bool, @@ -30,7 +49,9 @@ pub struct WhosHueApp { hovered_group: Option, highlight_texture: Option, mappings_enabled: bool, - vision_panel: bool, + sidebar_tab: Sidebar, + harmony: Harmony, + harmony_fit: harmony::Fit, channel: ui::Channel, region: Option, analysis: Analysis, @@ -62,6 +83,10 @@ impl WhosHueApp { crop_texture: None, simulated_texture: None, vision_mode: Vision::Original, + vision_preview: Vision::Original, + edited_analysis: None, + edited_palette_groups: Vec::new(), + palette_source: false, adjustments: Adjustments::default(), mappings: ColorMappings::default(), group_colors: true, @@ -70,7 +95,9 @@ impl WhosHueApp { hovered_group: None, highlight_texture: None, mappings_enabled: true, - vision_panel: false, + sidebar_tab: Sidebar::Colors, + harmony: Harmony::default(), + harmony_fit: harmony::Fit::default(), channel: ui::Channel::Hue, region: None, analysis: Analysis::default(), @@ -95,6 +122,8 @@ impl WhosHueApp { // A new selection always opens KDE's picker; never silently restore it. self.capture = Some(capture::spawn(None)); self.mappings = ColorMappings::default(); + self.harmony = Harmony::default(); + self.harmony_fit = harmony::Fit::default(); self.mappings_enabled = true; self.frame = None; self.texture = None; @@ -102,6 +131,9 @@ impl WhosHueApp { self.simulated_texture = None; self.region = None; self.analysis = Analysis::default(); + self.edited_analysis = None; + self.edited_palette_groups.clear(); + self.vision_preview = Vision::Original; self.rebuild_groups(); self.frozen = false; self.drag_origin = None; @@ -160,6 +192,7 @@ impl WhosHueApp { region.height, &pixels, ); + self.refit_harmony(); self.refresh_simulation(ctx); } } @@ -177,6 +210,36 @@ impl WhosHueApp { 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) { if self.hovered_group == index { return; @@ -187,7 +250,15 @@ impl WhosHueApp { && let Some(frame) = &self.frame { let region = self.region.unwrap_or_else(|| Region::full(frame)); - let pixels = self.palette_groups[index].highlight_rgba(®ion.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( ctx, &mut self.highlight_texture, @@ -216,31 +287,89 @@ impl WhosHueApp { } } + fn harmony_active(&self) -> bool { + self.can_map() && self.harmony.enabled + } + + fn refit_harmony(&mut self) { + if !self.harmony_active() { + return; + } + let lookup = if self.mappings_active() { + 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 { + let crop = self.mapped_crop(region, frame); + 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 { + 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 { + let crop = self.harmonized_crop(region, frame); + if self.vision_mode == Vision::Original { + self.adjustments.apply_rgba(&crop) + } else { + vision::simulate_rgba(&crop, self.vision_mode) + } + } + fn refresh_simulation(&mut self, ctx: &egui::Context) { - if self.vision_mode == Vision::Original + 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; } - if let Some(frame) = &self.frame { - let region = self.region.unwrap_or_else(|| Region::full(frame)); - let crop = self.mapped_crop(region, frame); - let pixels = if self.vision_mode == Vision::Original { - self.adjustments.apply_rgba(&crop) - } else { - vision::simulate_rgba(&crop, self.vision_mode) - }; - Self::texture( - ctx, - &mut self.simulated_texture, - "vision-preview", - region.width, - region.height, - &pixels, - ); - } + 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( + ctx, + &mut self.simulated_texture, + "vision-preview", + region.width, + region.height, + &pixels, + ); } fn load_image(&mut self, ctx: &egui::Context, path: &std::path::Path) { @@ -252,10 +381,13 @@ impl WhosHueApp { return; } self.stop(); + self.vision_preview = Vision::Original; self.region = None; self.drag_origin = None; self.frozen = true; self.mappings = ColorMappings::default(); + self.harmony = Harmony::default(); + self.harmony_fit = harmony::Fit::default(); self.mappings_enabled = true; self.set_frame( 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 { let r = self.region.unwrap_or_else(|| Region::full(frame)); // Unique output preserves earlier captures. @@ -297,11 +429,7 @@ impl WhosHueApp { let path = std::env::temp_dir().join(format!("whoshue-{stamp}.png")); self.message = match image::save_buffer( &path, - &if mapped { - self.mapped_crop(r, frame) - } else { - r.crop(frame) - }, + &self.export_pixels(r, frame, kind), r.width, r.height, image::ColorType::Rgba8, @@ -469,6 +597,18 @@ impl WhosHueApp { if previous_mode != self.vision_mode || previous_adjustments != self.adjustments { 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. let region = self.region.unwrap_or(Region { x: 0, @@ -489,9 +629,11 @@ impl WhosHueApp { }, ); 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() { - "Mapped preview · pixel inspection and analysis use original colors" + "Mapped preview · analysis uses edited colors · pixel inspection uses original colors" } else { "Pixel values and Save crop PNG use original colors" }); @@ -560,7 +702,7 @@ impl WhosHueApp { 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; egui::ScrollArea::vertical() .id_salt("mappings") @@ -602,46 +744,137 @@ impl WhosHueApp { ) .clicked() { - self.export(true); + self.export(Export::Mapped); } }); if changed { + self.refit_harmony(); self.refresh_simulation(ui.ctx()); } ui.separator(); } + fn harmony_controls(&mut self, ui: &mut egui::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| { + 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) { - self.mapping_controls(ui); - ui.horizontal(|ui| { - ui.selectable_value(&mut self.vision_panel, false, "Color analysis"); + 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 - .analysis + .current_analysis() .vision .scenarios .iter() .map(|s| s.pairs.len()) .sum(); ui.selectable_value( - &mut self.vision_panel, - true, + &mut self.sidebar_tab, + Sidebar::Vision, format!("Vision check ({count})"), ); }); ui.separator(); - if self.vision_panel { + if self.sidebar_tab == Sidebar::Harmony { 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() .id_salt("vision_warnings") .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()); } return; } + self.mapping_controls(ui); ui.heading("Color distribution"); ui.horizontal_wrapped(|ui| { 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, 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 { ui.label(format!( "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.separator(); 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 .checkbox(&mut self.group_colors, "Group similar colors") .changed(); @@ -671,13 +915,23 @@ impl WhosHueApp { } if regroup { 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!( "{} groups / {} colors · {} pixels", - self.palette_groups.len(), - self.analysis.palette.len(), - self.analysis.pixels + self.displayed_groups().len(), + palette_analysis.palette.len(), + palette_analysis.pixels )); ui.small("Most frequent first · click a swatch to copy hex"); let can_map = self.can_map(); @@ -687,10 +941,10 @@ impl WhosHueApp { egui::ScrollArea::vertical().id_salt("palette").show_rows( ui, 24.0, - self.palette_groups.len(), + self.displayed_groups().len(), |ui, rows| { for index in rows { - let swatch = &self.palette_groups[index]; + let swatch = &self.displayed_groups()[index]; let row = ui.horizontal(|ui| { let [r, g, b] = swatch.rgb; let hex = format!("#{r:02X}{g:02X}{b:02X}"); @@ -703,13 +957,13 @@ impl WhosHueApp { } ui.monospace(&hex); 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() { map_color = Some(index); } ui.label(format!( "{:.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())); let [h, s, v] = analysis::hsv(swatch.rgb); @@ -730,6 +984,7 @@ impl WhosHueApp { if let Some(index) = map_color { self.mappings.insert_group(&self.palette_groups[index]); self.mappings_enabled = true; + self.refit_harmony(); self.refresh_simulation(ui.ctx()); } } @@ -785,17 +1040,19 @@ impl eframe::App for WhosHueApp { .clicked() { self.frozen = !self.frozen; + self.refit_harmony(); self.refresh_simulation(&ctx); } if ui .add_enabled(has_frame, egui::Button::new("Save crop PNG")) .clicked() { - self.export(false); + self.export(Export::Original); } if self.capture.is_some() && ui.button("Stop sharing").clicked() { self.stop(); self.frozen = true; + self.refit_harmony(); 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::(), 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::(), 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] fn grouped_preview_and_highlight_reset_when_recropping() { let ctx = egui::Context::default(); @@ -998,6 +1531,8 @@ mod tests { 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(); 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(); app.file_picker = Some(receiver); sender.send(Ok(Some(path.clone()))).unwrap(); @@ -1008,6 +1543,8 @@ mod tests { assert!(app.capture.is_none()); assert!(app.frozen); 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]); } @@ -1089,7 +1626,11 @@ mod tests { for channel in ui::Channel::ALL { app.channel = channel; 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( egui::RawInput { screen_rect: Some(egui::Rect::from_min_size( diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 2615d0a..b4aba48 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -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) { ui.heading("Color-vision check"); 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 { ui.label("Select at least two opaque colors to check."); return; @@ -169,6 +173,12 @@ pub fn vision_report(ui: &mut egui::Ui, report: &Report, preview: &mut Vision) { for scenario in &report.scenarios { ui.separator(); ui.strong(scenario.mode.label()); + if ui + .button(format!("Preview {}", scenario.mode.label())) + .clicked() + { + *preview = scenario.mode; + } if scenario.pairs.is_empty() { ui.label("No strong color-merging candidates in checked colors."); continue; @@ -177,12 +187,6 @@ pub fn vision_report(ui: &mut egui::Ui, report: &Report, preview: &mut Vision) { "⚠ {} potentially confusing pairs", scenario.pairs.len() )); - if ui - .button(format!("Preview {}", scenario.mode.label())) - .clicked() - { - *preview = scenario.mode; - } for pair in scenario.pairs.iter().take(6) { ui.horizontal(|ui| { 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)] mod tests { use super::*;