mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-14 19:26:51 -04:00
Replace Color: Localized Color Clusters (points, localized, range) (#4477)
* Replace Color: Localized Color Clusters (points, localized, range) image.adjustments.replaceColor takes the Localized Color Clusters of Select › Color Range: "points" (where the colour was picked), "localized" and "range" (% of the canvas's longer side). With clusters on, the colour only matches near the points, through the same color_range_samples as Color Range, so the same colour elsewhere in the photo (a near-white background next to a cream garment) is left alone. Without "localized" nothing changes. localized without points, and malformed points, are refused. The dialog part follows once the canvas eyedropper of #4247 is in. Refs #4457 * Replace Color dialog: Localized Color Clusters and Range The dialog gets Photoshop's Localized Color Clusters checkbox and its Range slider (greyed while the box is off), and the canvas eyedropper (#4247) now also records where the colour was picked (points), so the localized match centres on that spot. The command's params list localized, range and points as real keys in Photoshop's order. Refs #4457
This commit is contained in:
1 parent
b3df06b8c4
commit
b007b1d355
4 files changed
+155
-26
No files matched your search
@@ -61,7 +61,14 @@ pub use shadows_highlights::shadows_highlights;
|
||||
/// Returns the selection mask (what the dialog previews).
|
||||
pub fn replace_color(px: &mut [[f32; 4]], color: [f32; 3], fuzziness: f32, hue: f32, saturation: f32, lightness: f32) -> Vec<f32> {
|
||||
let mask = crate::selection::color_range(px, color, fuzziness);
|
||||
for (q, &k) in px.iter_mut().zip(&mask) {
|
||||
replace_color_masked(px, &mask, hue, saturation, lightness);
|
||||
mask
|
||||
}
|
||||
|
||||
/// Replace Color's hue, saturation and lightness shift, applied to each pixel as far as `mask`
|
||||
/// (`0..=1`, one per pixel: the colour match, localized or not) covers it.
|
||||
pub fn replace_color_masked(px: &mut [[f32; 4]], mask: &[f32], hue: f32, saturation: f32, lightness: f32) {
|
||||
for (q, &k) in px.iter_mut().zip(mask) {
|
||||
if k <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
@@ -74,7 +81,6 @@ pub fn replace_color(px: &mut [[f32; 4]], color: [f32; 3], fuzziness: f32, hue:
|
||||
q[i] = (c[i] + (o[i] - c[i]) * k).clamp(0.0, 1.0);
|
||||
}
|
||||
}
|
||||
mask
|
||||
}
|
||||
|
||||
/// Lab mean and standard deviation of the opaque pixels, `[L, a, b]` each.
|
||||
|
||||
@@ -334,14 +334,54 @@ fn shadows_highlights_params(p: &Value) -> ShadowsHighlights {
|
||||
}
|
||||
|
||||
fn replace_color(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const CMD: &str = "image.adjustments.replaceColor";
|
||||
let c = crate::commands::color_param(p, "color", s.tools.foreground);
|
||||
let fuzz = num(p, "fuzziness", 40.0).clamp(0.0, 200.0);
|
||||
let (h, sat, l) = (num(p, "hue", 0.0).clamp(-180.0, 180.0), num(p, "saturation", 0.0).clamp(-100.0, 100.0), num(p, "lightness", 0.0).clamp(-100.0, 100.0));
|
||||
rgba_edit(s, "Replace Color", p, |px, _| {
|
||||
tone::replace_color(px, [c[0], c[1], c[2]], fuzz, h, sat, l);
|
||||
// Localized Color Clusters, as in Select › Color Range: the colour only matches within Range
|
||||
// (a percentage of the canvas's longer side) of where it was picked.
|
||||
let points = sample_points(p).map_err(|m| bad(CMD, m))?;
|
||||
let localized = if p.get("localized").and_then(Value::as_bool).unwrap_or(false) {
|
||||
if points.is_empty() {
|
||||
return Err(bad(CMD, "localized clusters need eyedropper `points` (where the colour was picked)"));
|
||||
}
|
||||
let canvas = s.active().ok_or(EngineError::NoDocument)?.doc.bounds();
|
||||
Some(num(p, "range", 100.0).clamp(0.0, 100.0) / 100.0 * canvas.width().max(canvas.height()) as f32)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
rgba_edit(s, "Replace Color", p, |px, r| match localized {
|
||||
Some(radius) => {
|
||||
let samples: Vec<_> = points
|
||||
.iter()
|
||||
.map(|&(x, y)| photocraft_algo::selection::RangeSample { color: [c[0], c[1], c[2]], at: Some((x - r.x0 as f32, y - r.y0 as f32)) })
|
||||
.collect();
|
||||
let mask = photocraft_algo::selection::color_range_samples(px, r.width() as usize, &samples, fuzz, Some(radius));
|
||||
tone::replace_color_masked(px, &mask, h, sat, l);
|
||||
}
|
||||
None => {
|
||||
tone::replace_color(px, [c[0], c[1], c[2]], fuzz, h, sat, l);
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// The `points` of a command (`[[x, y], …]` in document pixels), or a message saying what is wrong.
|
||||
fn sample_points(p: &Value) -> std::result::Result<Vec<(f32, f32)>, String> {
|
||||
let Some(pts) = p.get("points") else { return Ok(Vec::new()) };
|
||||
let pts = pts.as_array().ok_or("`points` must be [[x, y], …]")?;
|
||||
pts.iter()
|
||||
.map(|pt| {
|
||||
pt.as_array()
|
||||
.and_then(|a| match a.as_slice() {
|
||||
[x, y] => Some((x.as_f64()? as f32, y.as_f64()? as f32)),
|
||||
_ => None,
|
||||
})
|
||||
.filter(|(x, y)| x.is_finite() && y.is_finite())
|
||||
.ok_or_else(|| format!("bad point {pt} in `points` (want [x, y])"))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Straight RGBA pixels and an optional coverage mask for a document's layer (or its composite).
|
||||
/// Pixels plus an optional coverage mask.
|
||||
type Sample = (Vec<[f32; 4]>, Option<Vec<f32>>);
|
||||
@@ -454,7 +494,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"image.adjustments.replaceColor",
|
||||
"Replace Color…",
|
||||
["Image", "Adjustments"],
|
||||
r##"{"color":color,"fuzziness":0..200=40,"hue":-180..180=0,"saturation":-100..100=0,"lightness":-100..100=0} (color: "#rrggbb", default the foreground colour)"##,
|
||||
r##"{"localized":bool=false,"color":color,"fuzziness":0..200=40,"range":0..100=100,"hue":-180..180=0,"saturation":-100..100=0,"lightness":-100..100=0,"points":[[x,y],…]?} (color: "#rrggbb", default the foreground colour; Localized Color Clusters: "points" where the colour was picked, "range" % of the longer side)"##,
|
||||
has_pixels,
|
||||
replace_color
|
||||
),
|
||||
|
||||
@@ -503,3 +503,38 @@ fn a_targeted_mask_enables_editing_it_on_any_layer() {
|
||||
assert!(s.execute("image.adjustments.invert", mask).is_err());
|
||||
assert_eq!(rgba(&s, 23, 3), pixels);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replace_color_localized_clusters_only_change_the_colour_near_the_sample() {
|
||||
// The same cream on the left (x < 8) and the right (x ≥ 40), grey between.
|
||||
let cream = [0.9, 0.88, 0.79, 1.0];
|
||||
let mut s = session(8, "rgb");
|
||||
paint(&mut s, |x, _| if !(8..40).contains(&x) { cream } else { [0.5, 0.5, 0.5, 1.0] });
|
||||
let base = |s: &Session| (rgba(s, 3, 16), rgba(s, 44, 16), rgba(s, 20, 16));
|
||||
let (left, right, grey) = base(&s);
|
||||
let shift = json!({"color": "#e6e0c9", "fuzziness": 20, "hue": 180, "saturation": 60});
|
||||
// Without clusters the colour is replaced everywhere (unchanged behaviour).
|
||||
s.execute("image.adjustments.replaceColor", shift.clone()).unwrap();
|
||||
assert!(changed(rgba(&s, 3, 16), left) > 0.05 && changed(rgba(&s, 44, 16), right) > 0.05);
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
// Picked on the left with Range 50 % (24 px): only the left cream changes.
|
||||
let mut local = shift.clone();
|
||||
local["points"] = json!([[2, 16]]);
|
||||
local["localized"] = json!(true);
|
||||
local["range"] = json!(50);
|
||||
s.execute("image.adjustments.replaceColor", local.clone()).unwrap();
|
||||
assert!(changed(rgba(&s, 3, 16), left) > 0.05, "near the sample");
|
||||
assert_eq!(rgba(&s, 44, 16), right, "the same colour far from the sample");
|
||||
assert_eq!(rgba(&s, 20, 16), grey);
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
// Inside a selection that doesn't start at the origin the sample stays where it was picked.
|
||||
s.execute("select.rect", json!({"x": 1, "y": 4, "width": 46, "height": 24})).unwrap();
|
||||
s.execute("image.adjustments.replaceColor", local).unwrap();
|
||||
assert!(changed(rgba(&s, 3, 16), left) > 0.05);
|
||||
assert_eq!(rgba(&s, 44, 16), right);
|
||||
// Clusters need a place to be near.
|
||||
let err = s.execute("image.adjustments.replaceColor", json!({"localized": true})).unwrap_err();
|
||||
assert!(err.to_string().contains("points"), "{err}");
|
||||
let err = s.execute("image.adjustments.replaceColor", json!({"points": [[1]]})).unwrap_err();
|
||||
assert!(err.to_string().contains("bad point"), "{err}");
|
||||
}
|
||||
@@ -545,6 +545,10 @@ pub(crate) fn label(key: &str) -> String {
|
||||
}
|
||||
|
||||
pub(crate) fn source_label(key: &str) -> String {
|
||||
// Photoshop's name for the localized colour matching (Replace Color, Color Range).
|
||||
if key == "localized" {
|
||||
return "Localized Color Clusters".into();
|
||||
}
|
||||
// camelCase → "Camel Case"
|
||||
let mut s = String::new();
|
||||
for (i, ch) in key.chars().enumerate() {
|
||||
@@ -616,6 +620,8 @@ pub(crate) fn color_eyedropper_sample(app: &mut PhotocraftApp, x: f64, y: f64) -
|
||||
&& let Some(d) = app.ui.dialog_mut(id)
|
||||
{
|
||||
d.fields.insert("color".into(), json!(crate::color_picker_ui::hex(rgb)));
|
||||
// Where it was picked: Localized Color Clusters match the colour near this point (#4457).
|
||||
d.fields.insert("points".into(), json!([[x.floor(), y.floor()]]));
|
||||
}
|
||||
true
|
||||
}
|
||||
@@ -645,6 +651,8 @@ pub fn body(ui: &mut egui::Ui, f: &mut Map<String, Value>) {
|
||||
f.insert(p.key, json!(count));
|
||||
continue;
|
||||
}
|
||||
// Replace Color's Range only applies with Localized Color Clusters on, as in Photoshop.
|
||||
let enabled = !(cmd == REPLACE_COLOR && p.key == "range") || f.get("localized").and_then(Value::as_bool) == Some(true);
|
||||
let unit = if is_pixel_param_of(&cmd, &p.key) {
|
||||
"px"
|
||||
} else if p.key == "angle" {
|
||||
@@ -654,28 +662,33 @@ pub fn body(ui: &mut egui::Ui, f: &mut Map<String, Value>) {
|
||||
} else {
|
||||
""
|
||||
};
|
||||
if uses_angle_dial(&cmd, &p.key) {
|
||||
crate::widgets::angle_dial_row(ui, &label(&p.key), &mut v, min..=max);
|
||||
ui.add_space(4.0);
|
||||
} else if uses_logarithmic_slider(min, max) {
|
||||
// Keep one numeric field for direct entry and one log-scaled slider for
|
||||
// wide ranges. slider_row would add a second, linear slider (#146).
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(egui::RichText::new(label(&p.key)).color(t.text_dim));
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
crate::widgets::value_field(ui, &mut v, min..=max, unit, 74.0);
|
||||
});
|
||||
});
|
||||
let mut lv = v.clamp(min, max).ln();
|
||||
if crate::widgets::slider(ui, &mut lv, min.ln()..=max.ln(), None).changed() {
|
||||
v = lv.exp();
|
||||
ui.scope(|ui| {
|
||||
if !enabled {
|
||||
ui.disable();
|
||||
}
|
||||
ui.add_space(4.0);
|
||||
} else {
|
||||
// A double-click on the slider puts it back to the filter's default.
|
||||
let gestures = crate::widgets::RowGestures { reset: Some(default), wheel_step: None };
|
||||
crate::widgets::slider_row_with(ui, &label(&p.key), &mut v, min..=max, unit, None, gestures);
|
||||
}
|
||||
if uses_angle_dial(&cmd, &p.key) {
|
||||
crate::widgets::angle_dial_row(ui, &label(&p.key), &mut v, min..=max);
|
||||
ui.add_space(4.0);
|
||||
} else if uses_logarithmic_slider(min, max) {
|
||||
// Keep one numeric field for direct entry and one log-scaled slider for
|
||||
// wide ranges. slider_row would add a second, linear slider (#146).
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(egui::RichText::new(label(&p.key)).color(t.text_dim));
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
crate::widgets::value_field(ui, &mut v, min..=max, unit, 74.0);
|
||||
});
|
||||
});
|
||||
let mut lv = v.clamp(min, max).ln();
|
||||
if crate::widgets::slider(ui, &mut lv, min.ln()..=max.ln(), None).changed() {
|
||||
v = lv.exp();
|
||||
}
|
||||
ui.add_space(4.0);
|
||||
} else {
|
||||
// A double-click on the slider puts it back to the filter's default.
|
||||
let gestures = crate::widgets::RowGestures { reset: Some(default), wheel_step: None };
|
||||
crate::widgets::slider_row_with(ui, &label(&p.key), &mut v, min..=max, unit, None, gestures);
|
||||
}
|
||||
});
|
||||
// Small ranges (0–1 thresholds and centres) keep the two decimals their field shows.
|
||||
let scale = if max - min <= 10.0 { 100.0 } else { 10.0 };
|
||||
f.insert(p.key, json!((v * scale).round() / scale));
|
||||
@@ -2169,6 +2182,8 @@ mod tests {
|
||||
assert!(color_eyedropper_armed(&app));
|
||||
assert!(color_eyedropper_sample(&mut app, 4.0, 4.0));
|
||||
assert_eq!(app.ui.dialogs.last().unwrap().fields["color"], json!("#00ff00"));
|
||||
// #4457: where it was picked, for Localized Color Clusters.
|
||||
assert_eq!(app.ui.dialogs.last().unwrap().fields["points"], json!([[4.0, 4.0]]));
|
||||
// Off-image: the click is consumed, nothing changes and the eyedropper stays armed.
|
||||
assert!(color_eyedropper_sample(&mut app, -1.0, 4.0));
|
||||
assert_eq!(app.ui.dialogs.last().unwrap().fields["color"], json!("#00ff00"));
|
||||
@@ -2199,4 +2214,37 @@ mod tests {
|
||||
h.run_steps(2);
|
||||
assert!(!color_eyedropper_armed(h.state()));
|
||||
}
|
||||
|
||||
/// #4457: Localized Color Clusters and its Range, as in Photoshop's dialog; the sampled point
|
||||
/// and both settings reach the command.
|
||||
#[test]
|
||||
fn replace_color_localized_color_clusters() {
|
||||
use egui_kittest::Harness;
|
||||
use egui_kittest::kittest::Queryable;
|
||||
|
||||
// Left half red, right half red too: localized on the left point, the right half stays.
|
||||
let mut s = photocraft_engine::Session::new();
|
||||
s.execute("file.new", json!({"width": 40, "height": 10, "background": "#ff0000"})).unwrap();
|
||||
let mut app = PhotocraftApp::new(s, crate::Services::default());
|
||||
let mut h = Harness::builder().with_size(egui::vec2(900.0, 800.0)).build_ui_state(|ui, app| crate::dialogs::show(app, ui.ctx()), &mut app);
|
||||
PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::ALL[0]);
|
||||
let id = open(h.state_mut(), REPLACE_COLOR).unwrap();
|
||||
h.run_steps(3);
|
||||
let _ = h.get_by_label("Localized Color Clusters");
|
||||
h.state_mut().ui.dialog_mut(id).unwrap().fields.insert(COLOR_EYEDROPPER.into(), json!(true));
|
||||
assert!(color_eyedropper_sample(h.state_mut(), 2.0, 5.0));
|
||||
h.get_by_label("Localized Color Clusters").click();
|
||||
h.run_steps(2);
|
||||
let f = h.state().ui.dialogs.last().unwrap().fields.clone();
|
||||
assert_eq!((f["localized"].clone(), f["points"].clone()), (json!(true), json!([[2.0, 5.0]])));
|
||||
// The command with the dialog's fields, small range and a hue shift: only near (2, 5).
|
||||
let mut p: Map<String, Value> = f.iter().filter(|(k, _)| !k.starts_with("__")).map(|(k, v)| (k.clone(), v.clone())).collect();
|
||||
p.insert("range".into(), json!(10));
|
||||
p.insert("hue".into(), json!(120));
|
||||
h.state_mut().run(REPLACE_COLOR, Value::Object(p)).unwrap();
|
||||
let doc = &h.state().session.active().unwrap().doc;
|
||||
let px = |x: i32| doc.layers[0].surface().unwrap().read_region(photocraft_geom::Rect::new(x, 5, x + 1, 6));
|
||||
assert!(px(2)[1] > 0.5, "near the point: shifted to green {:?}", px(2));
|
||||
assert!(px(38)[0] > 0.9 && px(38)[1] < 0.1, "far away: still red {:?}", px(38));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user