mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-15 21:57:04 +00:00
Merge pull request #7785 from dotCooCoo/fix/automatic-keeps-every-level-of-clean-16-level
Image Trace: Automatic keeps every level of clean flat art of up to 16 levels
This commit is contained in:
@@ -13,7 +13,7 @@ pub(crate) use cut_edge::{continuous_tone, settle_cut_edges};
|
||||
pub use denoise::denoise;
|
||||
pub(crate) use denoise::denoise_keeping;
|
||||
pub use palette::FULL_TONE_MAX;
|
||||
use palette::{Bin, HistKey, TracedAs, Used, color_palette, flat_colors, kmeans_1d, labs, nearest_1d, nearest_lab, nearest_library};
|
||||
use palette::{Bin, HistKey, TracedAs, Used, color_palette, flat_count, flat_palette, kmeans_1d, labs, nearest_1d, nearest_lab, nearest_library};
|
||||
#[cfg(test)]
|
||||
pub(crate) use rare::kept_rare;
|
||||
use rare::{rare_colours, refine_around};
|
||||
@@ -126,12 +126,18 @@ pub(crate) fn quantize_keeping(img: &Raster, params: &TraceParams) -> (Quantized
|
||||
// Automatic on flat art: colours too rare for its count, but distinct and drawn as
|
||||
// regions of their own, are added (see `rare_colours`).
|
||||
let detail = (params.color_detail / 100.0).clamp(0.0, 1.0);
|
||||
let flat_auto = params.palette == Palette::Automatic && flat_colors(from, detail).is_some();
|
||||
let flat = if params.palette == Palette::Automatic { flat_count(from, detail) } else { None };
|
||||
let flat_auto = flat.is_some();
|
||||
let mut rare: Vec<[u8; 3]> = vec![];
|
||||
// The picture with `colors` colours asked for; fewer come back when it has fewer, or
|
||||
// when noise made several of one (see `merge_mixed`).
|
||||
let mut reduce = |colors: u32| {
|
||||
let mut centers = color_palette(from, &TraceParams { colors, ..params.clone() });
|
||||
// The picture in the colors `params` asks for, or in one color per clean level when
|
||||
// `levels` is given; fewer come back when it has fewer, or when noise made several of
|
||||
// one (see `merge_mixed`). Also whether `merge_mixed` merged any.
|
||||
let mut reduce = |levels: Option<usize>| {
|
||||
rare.clear();
|
||||
let mut centers = match levels {
|
||||
Some(n) => flat_palette(from, n),
|
||||
None => color_palette(from, params),
|
||||
};
|
||||
// The anti-aliasing beside a rare color along the cut edge, with the indexes of
|
||||
// that color and of the color on the other side of it (see `rare_colours`).
|
||||
let mut traced_as: Vec<TracedAs> = vec![];
|
||||
@@ -168,9 +174,24 @@ pub(crate) fn quantize_keeping(img: &Raster, params: &TraceParams) -> (Quantized
|
||||
let palette = centers.iter().map(|c| [c[0].round() as u8, c[1].round() as u8, c[2].round() as u8]).collect();
|
||||
let q = compact(labels, palette);
|
||||
// A swatch library's colours are used as they are.
|
||||
if library { q } else { merge_mixed(q, img.width as usize, img.height as usize, &rare) }
|
||||
if library {
|
||||
return (q, false);
|
||||
}
|
||||
let before = q.palette.len();
|
||||
let q = merge_mixed(q, img.width as usize, img.height as usize, &rare);
|
||||
let merged = q.palette.len() < before;
|
||||
(q, merged)
|
||||
};
|
||||
// Clean levels (see `flat_count`) each take a color unless `merge_mixed` finds two of
|
||||
// them mixed (dithering, or a thin region of a level beside a close one): such a
|
||||
// picture takes the colors median cut counts, with rare colors added.
|
||||
let q = match flat {
|
||||
Some((_, Some(levels))) => match reduce(Some(levels)) {
|
||||
(q, false) => q,
|
||||
(_, true) => reduce(None).0,
|
||||
},
|
||||
_ => reduce(None).0,
|
||||
};
|
||||
let q = reduce(params.colors);
|
||||
let q = if settles_wide(params) { settled_wide(q, img, &own, false) } else { settled(q, img, &own) };
|
||||
rare.retain(|c| q.palette.contains(c));
|
||||
(q, rare)
|
||||
|
||||
@@ -158,7 +158,7 @@ pub(super) fn color_palette(used: &[Used], params: &TraceParams) -> Vec<[f64; 3]
|
||||
Palette::Limited => limited(),
|
||||
Palette::FullTone => full_tone(),
|
||||
Palette::Automatic => match flat_colors(used, detail) {
|
||||
Some(n) => kmeans_rgb(used, n, 0.0),
|
||||
Some(n) => flat_palette(used, n),
|
||||
None => full_tone(),
|
||||
},
|
||||
Palette::DocumentLibrary if !params.swatches.is_empty() => library_colors(used, ¶ms.swatches, k),
|
||||
@@ -172,9 +172,20 @@ pub(super) fn color_palette(used: &[Used], params: &TraceParams) -> Vec<[f64; 3]
|
||||
/// noise aside): the bins holding 90% of the pixels are few, and each colour's pixels sit mostly
|
||||
/// in one bin. `detail` (0–1) lets smaller colour areas count as colours of their own.
|
||||
pub(super) fn flat_colors(used: &[Used], detail: f64) -> Option<usize> {
|
||||
flat_count(used, detail).map(|(n, _)| n)
|
||||
}
|
||||
|
||||
/// Automatic's palette of `n` colors for flat art.
|
||||
pub(super) fn flat_palette(used: &[Used], n: usize) -> Vec<[f64; 3]> {
|
||||
kmeans_rgb(used, n, 0.0)
|
||||
}
|
||||
|
||||
/// [`flat_colors`], and how many clean levels the picture is made of when it is made of them (see
|
||||
/// [`clean_levels`]).
|
||||
pub(super) fn flat_count(used: &[Used], detail: f64) -> Option<(usize, Option<usize>)> {
|
||||
let total: f64 = used.iter().map(|u| u.2).sum();
|
||||
if total <= 0.0 {
|
||||
return Some(2);
|
||||
return Some((2, None));
|
||||
}
|
||||
let mut by_count: Vec<&Used> = used.iter().collect();
|
||||
by_count.sort_by(|a, b| b.2.total_cmp(&a.2));
|
||||
@@ -195,12 +206,38 @@ pub(super) fn flat_colors(used: &[Used], detail: f64) -> Option<usize> {
|
||||
let colours: Vec<&ColorBox> = boxes.iter().filter(|b| b.weight >= min_share).collect();
|
||||
let weight: f64 = colours.iter().map(|b| b.weight).sum();
|
||||
let peaks: f64 = colours.iter().map(|b| b.peak).sum();
|
||||
(weight >= 0.9 * total && peaks >= 0.5 * weight && colours.len() <= 32).then_some(colours.len().max(2))
|
||||
if weight < 0.9 * total || peaks < 0.5 * weight || colours.len() > 32 {
|
||||
return None;
|
||||
}
|
||||
Some((colours.len().max(2), clean_levels(used, min_share)))
|
||||
}
|
||||
|
||||
/// The most histogram bins flat art's main colours spread over (a few bins per colour).
|
||||
const FLAT_BINS: usize = 96;
|
||||
|
||||
/// The most colors [`clean_levels`] counts.
|
||||
const CLEAN_LEVELS: usize = 16;
|
||||
|
||||
/// How far apart (levels of some channel) the mean colors of two bins must lie for
|
||||
/// [`clean_levels`] to count them as two colors.
|
||||
const LEVELS_APART: f64 = 12.0;
|
||||
|
||||
/// Flat art of 2 to [`CLEAN_LEVELS`] clean colors, one histogram bin each: the number of bins,
|
||||
/// when each holds at least `min_share` pixels and every two of them lie at least [`LEVELS_APART`]
|
||||
/// apart on some channel; otherwise `None`. Median cut keeps two colors less than 24 levels apart
|
||||
/// in one box, so evenly spaced levels 12 to 24 apart would otherwise count as fewer colors.
|
||||
/// Noise adds bins closer than that, or more than 16. An anti-aliased edge's blended pixels between
|
||||
/// colors more than 20 levels apart are not among `used` (see `own_colours`), and the steps of a
|
||||
/// blend between closer colors are less than [`LEVELS_APART`], so such pictures are counted by
|
||||
/// median cut.
|
||||
fn clean_levels(used: &[Used], min_share: f64) -> Option<usize> {
|
||||
if !(2..=CLEAN_LEVELS).contains(&used.len()) || used.iter().any(|u| u.2 < min_share) {
|
||||
return None;
|
||||
}
|
||||
let apart = |a: [f64; 3], b: [f64; 3]| (0..3).any(|ch| (a[ch] - b[ch]).abs() >= LEVELS_APART);
|
||||
used.iter().enumerate().all(|(i, a)| used[i + 1..].iter().all(|b| apart(a.1, b.1))).then_some(used.len())
|
||||
}
|
||||
|
||||
/// Document Library: the `k` swatches (at most) that the most pixels are nearest to (see
|
||||
/// [`nearest_library`]).
|
||||
fn library_colors(used: &[Used], swatches: &[[u8; 3]], k: usize) -> Vec<[f64; 3]> {
|
||||
@@ -438,6 +475,53 @@ mod tests {
|
||||
assert_eq!(palette(Palette::Automatic, 16), automatic);
|
||||
}
|
||||
|
||||
/// `colors`, each with `n` pixels, as histogram bins.
|
||||
fn bins(colors: impl IntoIterator<Item = ([f64; 3], f64)>) -> Vec<Used> {
|
||||
colors.into_iter().enumerate().map(|(i, (c, n))| (i, c, n)).collect()
|
||||
}
|
||||
|
||||
/// `levels` grays evenly spaced from black to white, 1,200 pixels each.
|
||||
fn gray_levels(levels: u32) -> Vec<([f64; 3], f64)> {
|
||||
(0..levels).map(|i| ([f64::from(i * 255 / (levels - 1)); 3], 1200.0)).collect()
|
||||
}
|
||||
|
||||
/// The clean levels `used` is made of, at the default Color Detail (see [`flat_count`]).
|
||||
fn clean(used: &[Used]) -> Option<usize> {
|
||||
flat_count(used, (TraceParams::default().color_detail / 100.0).clamp(0.0, 1.0)).and_then(|(_, levels)| levels)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn automatic_counts_every_clean_level_of_flat_art() {
|
||||
for levels in [2, 3, 10, 12, 14, 16] {
|
||||
assert_eq!(clean(&bins(gray_levels(levels))), Some(levels as usize), "{levels} grays");
|
||||
}
|
||||
// Sixteen sepia levels, about 13 apart on each channel.
|
||||
let sepia = (0..16u32).map(|i| {
|
||||
let t = f64::from(i) / 15.0;
|
||||
([45.0 + 205.0 * t, 28.0 + 207.0 * t, 15.0 + 190.0 * t].map(f64::round), 1200.0)
|
||||
});
|
||||
assert_eq!(clean(&bins(sepia)), Some(16), "16 sepia levels");
|
||||
// Levels 12 apart are the closest counted.
|
||||
let close = |apart: f64| bins((0..16).map(|i| ([40.0 + apart * f64::from(i); 3], 1200.0)));
|
||||
assert_eq!(clean(&close(12.0)), Some(16), "16 grays 12 apart");
|
||||
assert_eq!(clean(&close(11.0)), None, "16 grays 11 apart");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn noise_and_anti_aliasing_add_no_clean_levels() {
|
||||
// Eight grays 34 apart, each with a second bin 6 levels off it holding as many pixels (noise).
|
||||
let noisy = gray_levels(8).into_iter().flat_map(|(c, n)| [(c, n), (c.map(|v| (v + 6.0).min(255.0)), n)]);
|
||||
assert_eq!(clean(&bins(noisy)), None, "eight noisy grays");
|
||||
// Eight grays with a few blended pixels halfway between each two (anti-aliasing).
|
||||
let levels = gray_levels(8);
|
||||
let blends = levels.windows(2).map(|w| ([(w[0].0[0] + w[1].0[0]) / 2.0; 3], 60.0));
|
||||
assert_eq!(clean(&bins(levels.iter().copied().chain(blends))), None, "eight anti-aliased grays");
|
||||
// Seventeen levels or more are counted by median cut.
|
||||
assert_eq!(clean(&bins((0..17).map(|i| ([15.0 * f64::from(i); 3], 1200.0)))), None, "17 grays");
|
||||
// A continuous-tone picture has no count at all.
|
||||
assert_eq!(clean(&ground_and_ramp()), None, "ground and ramp");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_dark_colored_figure_on_a_nearly_flat_dark_ground_keeps_its_color() {
|
||||
// Dark navy text (a few pixels) on the near-black ground: only cuts between two dark grays
|
||||
|
||||
@@ -2322,6 +2322,65 @@ fn thin_lines_on_flat_art_of_up_to_sixteen_clean_levels_are_traced() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn automatic_keeps_every_level_of_clean_flat_art_of_up_to_sixteen_levels() {
|
||||
let tints: [(&str, &[[f64; 3]]); 4] = [("gray", &GRAY_RAMP), ("sepia", &SEPIA), ("blue", &BLUE), ("duotone", &DUOTONE)];
|
||||
let params = TraceParams { mode: Mode::Color, palette: Palette::Automatic, ..TraceParams::default() };
|
||||
for (name, stops) in tints {
|
||||
for levels in [2, 10, 12, 14, 16] {
|
||||
let bands = tinted(&even_gray_bands(levels), stops, 0.0);
|
||||
let res = trace(&bands, ¶ms);
|
||||
assert_eq!(res.palette.len(), levels as usize, "{levels} {name} bands: {:?}", res.palette);
|
||||
for band in 0..levels {
|
||||
let i = ((band * 20 + 10) * 4) as usize;
|
||||
let want = [bands.rgba[i], bands.rgba[i + 1], bands.rgba[i + 2]];
|
||||
assert!(
|
||||
res.palette.iter().any(|c| (0..3).all(|k| c[k].abs_diff(want[k]) <= 1)),
|
||||
"{levels} {name} bands: no {want:?} in {:?}",
|
||||
res.palette
|
||||
);
|
||||
}
|
||||
assert_eq!(res.paths.len(), levels as usize, "{levels} {name} bands");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn automatic_counts_no_anti_aliased_edge_of_clean_levels_as_a_level() {
|
||||
// Four gray bands 10 px wide, `apart` levels apart, each edge blended over `width` px: every
|
||||
// blended column holds 2.5 in 100 of the pixels, more than a color needs to be counted.
|
||||
let params = TraceParams { mode: Mode::Color, palette: Palette::Automatic, ..TraceParams::default() };
|
||||
for apart in [24.0, 40.0, 64.0] {
|
||||
for width in 1..=4u32 {
|
||||
let level = |b: u32| 20.0 + apart * f64::from(b);
|
||||
let img = Raster::from_fn(40, 60, |x, _| {
|
||||
let (b, j) = (x / 10, x % 10);
|
||||
let v = if b > 0 && j < width { level(b - 1) + apart * f64::from(j + 1) / f64::from(width + 1) } else { level(b) };
|
||||
let v = v.round() as u8;
|
||||
[v, v, v, 255]
|
||||
});
|
||||
let res = trace(&img, ¶ms);
|
||||
assert_eq!(res.palette.len(), 4, "levels {apart} apart, edges {width} px: {:?}", res.palette);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn automatic_merges_a_dithered_close_level_as_noise() {
|
||||
// A gray ground with 5 to 20 in 100 of its pixels, scattered, 12 levels lighter: two clean
|
||||
// levels by their histogram, but mixed through each other, so they trace as one color.
|
||||
let params = TraceParams { mode: Mode::Color, palette: Palette::Automatic, ..TraceParams::default() };
|
||||
let hash = |x: u32, y: u32| (x.wrapping_mul(73_856_093) ^ y.wrapping_mul(19_349_663)).wrapping_mul(2_654_435_761);
|
||||
for percent in [5, 10, 20] {
|
||||
let img = Raster::from_fn(120, 120, |x, y| {
|
||||
let v = if hash(x, y) % 100 < percent { 112 } else { 100 };
|
||||
[v, v, v, 255]
|
||||
});
|
||||
let res = trace(&img, ¶ms);
|
||||
assert_eq!(res.palette.len(), 1, "{percent} in 100 dithered: {:?}", res.palette);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pictures_without_tones_are_not_continuous_tone() {
|
||||
let photo = tinted(&photo_scene(6.0, true), &SEPIA, 2.0);
|
||||
|
||||
@@ -1486,6 +1486,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
|
||||
- **Image Trace, gray photos:** Color and Grayscale smooth a gray photo within its tones before choosing its colors, as they do a color photo. A picture with at least 95% of its opaque pixels within 8 levels across their channels counts as continuous tone when 8 colors leave an error of more than 6 levels and more than 0.065 of the error of one color, instead of 0.1, unless 16 colors leave at most a tenth of the error of 8 (VectorCraft's own values). That exception keeps gray flat art of up to 16 clean levels on its old route; anti-aliased or noisy gray flat art can now count as continuous tone too. Most gray photos the change reaches, and very noisy gray scans of flat art, trace with fewer specks and fewer staircase edges. Some 16 Colors and High Fidelity Photo palettes of gray photos grow toward the requested count, with more anchors and regions. Some gray illustrations and sketches get more anchors and staircase edges at 16 Colors and High Fidelity Photo, and some 6 Colors traces get more staircase edges. Tests: `tests::a_gray_photo_is_continuous_tone_as_its_color_original_is`, `tests::a_gray_photo_is_smoothed_before_it_is_traced`. Scores and hours unchanged.
|
||||
- **Image Trace, tinted photos:** Color and Grayscale smooth a sepia, single-hue or duotone photo within its tones before choosing its colors, as they do a gray photo. A picture with at least 95% of its opaque pixels within 6 levels of its tone curve counts as continuous tone under the same 0.065 rule as a neutral picture. The tone curve runs through the median color of each 8-level stretch along the direction in which the picture's colors spread the most, and a pixel on it must also lie within 6 levels of the plane the colors spread along most, so art in several unrelated colors is not counted (VectorCraft's own rule and values). The neutral rule's exception for 16 colors leaving at most a tenth of the error of 8 applies to it too. Clean tinted bands of 2, 3 and 9 levels and tinted scans of flat art with up to 8 levels of noise stay on the flat-art route. On the synthetic tinted photos the 16 Colors trace keeps 12 to 15 colors instead of 6 to 13, with 525 to 708 anchors instead of up to 1,402. A duotone with strong noise on each channel can still miss the curve and stay unsmoothed. Tests: `tests::a_tinted_photo_is_continuous_tone_as_a_gray_photo_is`, `tests::small_accents_of_other_colors_leave_a_tinted_photo_continuous_tone`, `tests::tinted_flat_art_stays_flat_art`, `tests::pictures_without_tones_are_not_continuous_tone`, `tests::a_tinted_photo_is_smoothed_before_it_is_traced`. Scores and hours unchanged.
|
||||
- **Image Trace, flat art of 10 to 16 levels:** the exception for 16 colors leaving at most a tenth of the error of 8 also applies to a neutral or tinted picture whose 8-color error is more than 0.1 of the error of one color (VectorCraft's own values), so Color and Grayscale trace clean gray, sepia, single-hue and duotone art of 10 to 16 evenly spaced levels as flat art, without smoothing it first. At 16 Colors, 16 gray or sepia levels crossed by two 1 px lines trace as the 35 regions the lines cut them into instead of 33, and Automatic traces 16 clean sepia levels in 9 colors instead of 4. Tests: `tests::flat_art_of_up_to_sixteen_clean_levels_stays_flat_art`, `tests::thin_lines_on_flat_art_of_up_to_sixteen_clean_levels_are_traced`. Scores and hours unchanged.
|
||||
- **Image Trace, Automatic on clean flat art of up to 16 levels:** when flat art's pixels fall in 2 to 16 histogram bins, each holding at least the share a color needs to be counted, and every two bins lie at least 12 levels apart on some channel (VectorCraft's own value), Automatic counts one color per bin, unless mixed-color merging then finds two of them mixed through each other (dithering, or a thin region beside a close level), in which case it counts colors as before. Median cut kept levels less than 24 apart in one box, so 16 clean gray levels traced in 14 colors and 16 sepia levels in 9; gray, sepia, single-hue and duotone art of 2 to 16 evenly spaced levels now keeps every level. Anti-aliased art whose levels lie more than 20 apart is counted level by level too, because its blended edge pixels are left out of the count. Noisy pictures, anti-aliased art whose levels lie 20 or fewer apart, and pictures of 17 levels or more are counted by median cut as before, and continuous-tone classification is unchanged. Tests: `tests::automatic_keeps_every_level_of_clean_flat_art_of_up_to_sixteen_levels`, `tests::automatic_counts_no_anti_aliased_edge_of_clean_levels_as_a_level`, `tests::automatic_merges_a_dithered_close_level_as_noise`, `quantize::palette::tests::automatic_counts_every_clean_level_of_flat_art`, `quantize::palette::tests::noise_and_anti_aliasing_add_no_clean_levels`. Scores and hours unchanged.
|
||||
- **Image Trace, clean gray photos:** the exception for flat art of up to 16 clean levels now applies when 16 colors leave at most a tenth of the error of 8, instead of a fifth (VectorCraft's own values). On the synthetic gray photo with 0 to 2 levels of noise, 16 colors leave 0.19 of the error of 8, so Color and Grayscale now count it as continuous tone and smooth it before tracing, as they do its color original. Clean gray, sepia, single-hue and duotone art of up to 16 levels, where 16 colors leave no error, stays on the flat-art route. Test: `tests::a_clean_gray_photo_is_continuous_tone_as_its_color_original_is`. Scores and hours unchanged.
|
||||
- **Image Trace, JPEG blocks:** Color, Grayscale and Black and White traces smooth the edges of a JPEG's 8 × 8 blocks before tracing, so a dark ground whose tone changes slowly is no longer traced as staircases along the block edges. The block grid is found in the image itself: the mean step between neighboring pixels must peak at one position in eight, across and down. A cropped JPEG and a JPEG saved in another format are smoothed too. A picture of flat 8 × 8 cells is left as it is, and art made of 8 × 8 cells with slight texture inside each cell is smoothed as a JPEG copy would be. A small step at a block edge, with no edge in the eight pixels around it, is spread over those eight pixels, and larger steps are kept, as the deblocking filters of ITU-T H.263 Annex J and MPEG-4 Part 2 Annex F.3.1 do. Black and White smooths only the smallest steps, and traces of more than 16 colors move every row of a stretch of eight by the same amount (VectorCraft's own rule and values). On a 4000 × 3000 photo saved as JPEG, the straight outline segments that lie on the block grid fall from 52% and 51% (across and down) to 24% and 29% at 16 Colors, and from 54% and 51% to 43% and 42% at High Fidelity Photo; staircase runs fall from 83 to 67 (16 Colors), 11 to 1 (6 Colors), 30 to 13 (Black and White Logo) and 428 to 403 (High Fidelity Photo). A 600 px photo saved as a quality-40 JPEG has 4 staircase runs instead of 2 at 16 Colors. Images without a block grid trace as before. Tests: `tests::a_jpegs_blocks_are_not_traced_as_steps`, `deblock::tests`. Scores and hours unchanged.
|
||||
- **Image Trace, Document Library color groups:** with Document Swatches, the panel's Colors menu and `"colorGroup"` trace with one color group's colors instead of every swatch, as the public panel documentation describes Document Library; code-drawn tests in `crates/engine` and the panel.
|
||||
|
||||
Reference in New Issue
Block a user