Add harmonize utils
This commit is contained in:
@@ -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.
|
||||
|
||||
|
||||
@@ -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(¢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<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
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -122,12 +122,15 @@ impl ColorMappings {
|
||||
self.0.last_mut().unwrap().target = target;
|
||||
}
|
||||
|
||||
pub fn apply_rgba(&self, rgba: &[u8]) -> Vec<u8> {
|
||||
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<u8> {
|
||||
let lookup = self.lookup();
|
||||
let mut output = rgba.to_vec();
|
||||
for pixel in output.as_chunks_mut::<4>().0 {
|
||||
if pixel[3] != 0
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<u8> {
|
||||
}
|
||||
|
||||
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<PairWarning>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct Report {
|
||||
pub scenarios: Vec<Scenario>,
|
||||
pub colors_checked: usize,
|
||||
|
||||
+586
-45
@@ -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<SharedCapture>,
|
||||
config: Config,
|
||||
@@ -22,6 +37,10 @@ pub struct WhosHueApp {
|
||||
crop_texture: Option<egui::TextureHandle>,
|
||||
simulated_texture: Option<egui::TextureHandle>,
|
||||
vision_mode: Vision,
|
||||
vision_preview: Vision,
|
||||
edited_analysis: Option<Analysis>,
|
||||
edited_palette_groups: Vec<ColorGroup>,
|
||||
palette_source: bool,
|
||||
adjustments: Adjustments,
|
||||
mappings: ColorMappings,
|
||||
group_colors: bool,
|
||||
@@ -30,7 +49,9 @@ pub struct WhosHueApp {
|
||||
hovered_group: Option<usize>,
|
||||
highlight_texture: Option<egui::TextureHandle>,
|
||||
mappings_enabled: bool,
|
||||
vision_panel: bool,
|
||||
sidebar_tab: Sidebar,
|
||||
harmony: Harmony,
|
||||
harmony_fit: harmony::Fit,
|
||||
channel: ui::Channel,
|
||||
region: Option<Region>,
|
||||
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<usize>) {
|
||||
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,22 +287,81 @@ impl WhosHueApp {
|
||||
}
|
||||
}
|
||||
|
||||
fn refresh_simulation(&mut self, ctx: &egui::Context) {
|
||||
if self.vision_mode == Vision::Original
|
||||
&& self.adjustments == Adjustments::default()
|
||||
&& !self.mappings_active()
|
||||
{
|
||||
self.simulated_texture = None;
|
||||
fn harmony_active(&self) -> bool {
|
||||
self.can_map() && self.harmony.enabled
|
||||
}
|
||||
|
||||
fn refit_harmony(&mut self) {
|
||||
if !self.harmony_active() {
|
||||
return;
|
||||
}
|
||||
if let Some(frame) = &self.frame {
|
||||
let region = self.region.unwrap_or_else(|| Region::full(frame));
|
||||
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<u8> {
|
||||
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)
|
||||
} else {
|
||||
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(
|
||||
ctx,
|
||||
&mut self.simulated_texture,
|
||||
@@ -241,7 +371,6 @@ impl WhosHueApp {
|
||||
&pixels,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn load_image(&mut self, ctx: &egui::Context, path: &std::path::Path) {
|
||||
match image::open(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 sidebar(&mut self, ui: &mut egui::Ui) {
|
||||
self.mapping_controls(ui);
|
||||
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| {
|
||||
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
|
||||
.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::<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]
|
||||
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(
|
||||
|
||||
+58
-6
@@ -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::*;
|
||||
|
||||
Reference in New Issue
Block a user