From 3ee9c02acf1928c98bb67dc33a7da56d2de7e3a6 Mon Sep 17 00:00:00 2001 From: ExPikaPaka Date: Mon, 12 Oct 2026 12:37:57 +0200 Subject: [PATCH] feat: sharpen, two colours, and remove background in the Image tool Three more corrections, chosen because the output is a handful of flat filament colours rather than a continuous image: what helps is whatever decides which filament an area lands in and where the boundary between two of them falls. - Sharpen. An unsharp mask over the colour, reusing the sliding-window blur the Smoothing slider already uses, run per channel. Worth more here than in a photo editor: a soft edge between two colours quantizes into a ragged band of both, and sharpening first makes it land in one place. - Two colours. Thresholds on luma so a logo or line art prints in exactly two filaments instead of speckling across several, with the split point on a slider. - Remove background. The one that is not an RGB transform. DecodedHeightTexture gains an alpha plane, filled from the source PNG's fourth channel when it has one, and the key additionally clears whatever colour the picture's four corners share. A transparent texel is not black: sample_layer_color()'s covered() now reports it the same way it reports being outside an untiled placement, so the triangle keeps the object's own colour and the bake needs no new concept. The preview follows: upload_color_texture() box-filters coverage into the alpha channel it used to fill with 255, and the shader leaves the surface alone where it is below half. The corners rather than an eyedropper: a background that does not reach all four of them is not a background, and a picker would be a second interaction for the case this exists to make one click. The median of the four is taken so one stray corner cannot decide it. Measured on Tartan, a cube face, four filaments: run out tris coloured max offset mm filament shares image, no correction 22534 14544 0.000005 1:91% 3:8% 4:1% image, sharpen 1.0 24780 16783 0.000004 1:84% 3:12% 4:3% image, two colors 14798 6837 0.000005 2:22% 4:78% image, bg removed 14120 2620 0.000005 1:29% 3:16% 4:55% Background removal cuts the painted area from 14544 triangles to 2620, which is the whole point of it; the threshold collapses to two filaments; sharpen shifts the shares and adds boundary triangles. None of them moves the surface. The probe's default scene is now Tartan rather than Colour Bricks, which has too little luma range for the threshold to show anything. --- .../110/texture_displacement_shaded.fs | 9 +- .../140/texture_displacement_shaded.fs | 9 +- src/libslic3r/TextureDisplacement.cpp | 123 +++++++++++++++++- src/libslic3r/TextureDisplacement.hpp | 24 +++- src/slic3r/GUI/Gizmos/GLGizmoImage.cpp | 34 +++++ .../GUI/Gizmos/GLGizmoTextureDisplacement.cpp | 10 +- .../GUI/Gizmos/GLGizmoTextureDisplacement.hpp | 11 +- 7 files changed, 206 insertions(+), 14 deletions(-) diff --git a/resources/shaders/110/texture_displacement_shaded.fs b/resources/shaders/110/texture_displacement_shaded.fs index 6737b6e560..a1e1ac8069 100644 --- a/resources/shaders/110/texture_displacement_shaded.fs +++ b/resources/shaders/110/texture_displacement_shaded.fs @@ -357,7 +357,12 @@ void main() // Only the albedo - the specular term (intensity.y) stays white - so a coloured fragment reads as // the same material under the same light, and the relief this preview exists to show is unaffected. vec3 albedo = uniform_color.rgb; - if (palette_count > 0 && has_color_tex && have_uv && weight > 0.0) - albedo = palette_rgb[nearest_palette_entry(texture2D(color_tex, color_uv).rgb)]; + // The fourth channel is coverage: where the picture is not there, the surface keeps its own + // colour rather than being painted the black that a transparent texel's rgb reads as. + if (palette_count > 0 && has_color_tex && have_uv && weight > 0.0) { + vec4 tex_rgba = texture2D(color_tex, color_uv); + if (tex_rgba.a >= 0.5) + albedo = palette_rgb[nearest_palette_entry(tex_rgba.rgb)]; + } gl_FragColor = vec4(vec3(intensity.y) + albedo * intensity.x, uniform_color.a); } diff --git a/resources/shaders/140/texture_displacement_shaded.fs b/resources/shaders/140/texture_displacement_shaded.fs index b5c68c0069..e90de36ad1 100644 --- a/resources/shaders/140/texture_displacement_shaded.fs +++ b/resources/shaders/140/texture_displacement_shaded.fs @@ -445,7 +445,12 @@ void main() // Only the albedo - the specular term (intensity.y) stays white - so a coloured fragment reads as // the same material under the same light, and the relief this preview exists to show is unaffected. vec3 albedo = uniform_color.rgb; - if (palette_count > 0 && has_color_tex && have_uv && weight > 0.0) - albedo = palette_rgb[nearest_palette_entry(texture(color_tex, color_uv).rgb)]; + // The fourth channel is coverage: where the picture is not there, the surface keeps its own + // colour rather than being painted the black that a transparent texel's rgb reads as. + if (palette_count > 0 && has_color_tex && have_uv && weight > 0.0) { + vec4 tex_rgba = texture(color_tex, color_uv); + if (tex_rgba.a >= 0.5) + albedo = palette_rgb[nearest_palette_entry(tex_rgba.rgb)]; + } out_color = vec4(vec3(intensity.y) + albedo * intensity.x, uniform_color.a); } diff --git a/src/libslic3r/TextureDisplacement.cpp b/src/libslic3r/TextureDisplacement.cpp index ff6b95456c..334a5650d1 100644 --- a/src/libslic3r/TextureDisplacement.cpp +++ b/src/libslic3r/TextureDisplacement.cpp @@ -115,6 +115,20 @@ float DecodedHeightTexture::sample(const Vec2f &uv, bool tile_enabled, TextureTi return top * (1.f - ty) + bottom * ty; } +float DecodedHeightTexture::sample_alpha(const Vec2f &uv, bool tile_enabled, TextureTileMethod tile_method) const +{ + TexelTap tap; + if (!has_alpha()) + return 1.f; // no coverage plane means the picture is everywhere, which is the usual case + if (!texel_tap(uv, tile_enabled, tile_method, tap)) + return 0.f; + + auto at = [this](int x, int y) { return float(alpha[size_t(y) * size_t(width) + size_t(x)]) / 255.f; }; + const float top = at(tap.x0, tap.y0) * (1.f - tap.tx) + at(tap.x1, tap.y0) * tap.tx; + const float bottom = at(tap.x0, tap.y1) * (1.f - tap.tx) + at(tap.x1, tap.y1) * tap.tx; + return top * (1.f - tap.ty) + bottom * tap.ty; +} + Vec3f DecodedHeightTexture::sample_color(const Vec2f &uv, bool tile_enabled, TextureTileMethod tile_method) const { TexelTap tap; @@ -261,10 +275,93 @@ bool height_texture_has_color(const TextureDisplacementLayer &layer) return decode_height_texture(layer).has_color(); } +namespace { +// Unsharp mask over the colour: blur a copy and push each texel away from it. The blur is the same +// sliding-window helper the Smoothing slider uses, run per channel, so the cost is the image size +// rather than the image size times the radius. +// +// Worth having here for a reason it would not be in a photo editor: the picture is about to be +// reduced to a handful of flat colours, and a soft edge between two of them quantizes into a ragged +// band of both. Sharpening first makes that boundary land in one place. +void sharpen_rgb(DecodedHeightTexture &tex, float amount) +{ + if (!tex.has_color() || amount <= 0.f) + return; + const size_t n = size_t(tex.width) * size_t(tex.height); + // A small radius, scaled with the image so a 4k picture is not sharpened a tenth as hard as a + // 512 one. Deliberately not tied to `amount`: that is how much, this is how wide. + const int radius = std::max(1, int(std::lround(double(std::min(tex.width, tex.height)) / 512.0))); + const float k = std::clamp(amount, 0.f, 1.f) * 1.5f; + + std::vector plane(n); + for (int c = 0; c < 3; ++c) { + for (size_t i = 0; i < n; ++i) + plane[i] = tex.rgb[i * 3 + size_t(c)]; + smooth_height_pixels_box(plane, tex.width, tex.height, radius); + for (size_t i = 0; i < n; ++i) { + const float sharp = float(tex.rgb[i * 3 + size_t(c)]) + k * (float(tex.rgb[i * 3 + size_t(c)]) - float(plane[i])); + tex.rgb[i * 3 + size_t(c)] = uint8_t(std::lround(std::clamp(sharp, 0.f, 255.f))); + } + } +} + +// Two levels, on luma. For line art and logos, where the useful result is exactly two filaments and +// anything in between is speckle. +void apply_threshold(DecodedHeightTexture &tex, const TextureDisplacementLayer &layer) +{ + if (!tex.has_color() || !layer.threshold_enabled) + return; + const float cut = std::clamp(layer.threshold, 0.f, 1.f) * 255.f; + const size_t n = size_t(tex.width) * size_t(tex.height); + for (size_t i = 0; i < n; ++i) { + const float luma = 0.2126f * float(tex.rgb[i * 3]) + 0.7152f * float(tex.rgb[i * 3 + 1]) + + 0.0722f * float(tex.rgb[i * 3 + 2]); + const uint8_t v = luma >= cut ? 255 : 0; + tex.rgb[i * 3] = tex.rgb[i * 3 + 1] = tex.rgb[i * 3 + 2] = v; + } +} + +// Keys out the colour the picture's corners share, so a logo on a white card prints as the logo and +// the object's own colour shows through the rest. +// +// The corners rather than a colour the user picks: a background that does not reach all four of them +// is not a background, and asking for an eyedropper would be a second interaction for the case this +// exists to make one click. The median of the four is used so a single stray corner cannot decide it. +void remove_background(DecodedHeightTexture &tex, const TextureDisplacementLayer &layer) +{ + if (!tex.has_color() || !layer.bg_remove || tex.width < 2 || tex.height < 2) + return; + const size_t n = size_t(tex.width) * size_t(tex.height); + const size_t corner[4] = { 0, size_t(tex.width) - 1, (size_t(tex.height) - 1) * size_t(tex.width), + n - 1 }; + float key[3]; + for (int c = 0; c < 3; ++c) { + std::array v{}; + for (int i = 0; i < 4; ++i) + v[size_t(i)] = float(tex.rgb[corner[i] * 3 + size_t(c)]); + std::sort(v.begin(), v.end()); + key[c] = 0.5f * (v[1] + v[2]); + } + // Euclidean in 0..255 RGB, against a tolerance scaled to the diagonal of that cube, so the + // slider reads as "how different from the background still counts as background". + const float limit = std::clamp(layer.bg_tolerance, 0.f, 1.f) * 441.67f; + if (!tex.has_alpha()) + tex.alpha.assign(n, 255); + for (size_t i = 0; i < n; ++i) { + const float dr = float(tex.rgb[i * 3]) - key[0]; + const float dg = float(tex.rgb[i * 3 + 1]) - key[1]; + const float db = float(tex.rgb[i * 3 + 2]) - key[2]; + if (std::sqrt(dr * dr + dg * dg + db * db) <= limit) + tex.alpha[i] = 0; + } +} +} // namespace + bool has_color_adjustments(const TextureDisplacementLayer &layer) { return layer.brightness != 0.f || layer.contrast != 0.f || layer.saturation != 1.f || - layer.gamma != 1.f || layer.color_invert || layer.hue != 0.f; + layer.gamma != 1.f || layer.color_invert || layer.hue != 0.f || layer.sharpness > 0.f || + layer.threshold_enabled || layer.bg_remove; } void apply_color_adjustments(DecodedHeightTexture &tex, const TextureDisplacementLayer &layer) @@ -336,6 +433,14 @@ void apply_color_adjustments(DecodedHeightTexture &tex, const TextureDisplacemen } } }); + + // Sharpen, threshold and the background key are all passes over the whole image rather than + // per-texel arithmetic, so they come after the loop above and in this order: sharpening a + // thresholded image would only ring along the one edge it has left, and the key has to read the + // colours the user is actually looking at. + sharpen_rgb(tex, layer.sharpness); + apply_threshold(tex, layer); + remove_background(tex, layer); } DecodedHeightTexture decode_height_texture(const TextureDisplacementLayer &layer) @@ -390,6 +495,10 @@ DecodedHeightTexture decode_height_texture(const TextureDisplacementLayer &layer result.height = h; result.pixels.resize(size_t(w) * size_t(h)); result.rgb.resize(size_t(w) * size_t(h) * 3); + // A fourth channel is coverage, and it is the only thing that can say "the picture is + // not here" as opposed to "the picture is black here". Kept only when the source has it. + if (bpp >= 4) + result.alpha.resize(size_t(w) * size_t(h)); // decode_colored_png() hands its buffer back bottom-up - it is shared with the CLI's // plate-thumbnail loader, which expects that - while this type, and decode_png()'s // grayscale path above, are top-to-bottom. Reverse the rows on the way in so a colour @@ -403,6 +512,8 @@ DecodedHeightTexture decode_height_texture(const TextureDisplacementLayer &layer result.rgb[dst * 3 + 1] = g; result.rgb[dst * 3 + 2] = b; result.pixels[dst] = uint8_t(std::lround(0.299 * r + 0.587 * g + 0.114 * b)); + if (bpp >= 4) + result.alpha[dst] = src[3]; } } @@ -1936,10 +2047,14 @@ bool sample_layer_color(const DecodedHeightTexture &texture, const TextureDispla // Outside a non-tiled placement there is no texture at all - the same hard edge sample() gives the // height. Checked explicitly because sample_color() reports it as black, which is a real colour. auto covered = [&](const Vec2f &planar) { - if (layer.tile_enabled) - return true; const Vec2f uv = xf(planar); - return uv.x() >= 0.f && uv.x() < 1.f && uv.y() >= 0.f && uv.y() < 1.f; + if (!layer.tile_enabled && !(uv.x() >= 0.f && uv.x() < 1.f && uv.y() >= 0.f && uv.y() < 1.f)) + return false; + // Where the picture is transparent there is no colour to print, which is the same answer as + // being outside the placement - not black, which is a colour. Half coverage counts as + // covered: a triangle prints in one filament either way, so the only useful question is + // which side of the edge it falls on. + return texture.sample_alpha(uv, layer.tile_enabled, layer.tile_method) >= 0.5f; }; if (lscm_uv != nullptr) { diff --git a/src/libslic3r/TextureDisplacement.hpp b/src/libslic3r/TextureDisplacement.hpp index 7dea145d9a..c22a1fae97 100644 --- a/src/libslic3r/TextureDisplacement.hpp +++ b/src/libslic3r/TextureDisplacement.hpp @@ -300,6 +300,16 @@ struct TextureDisplacementLayer float brightness = 0.f; // -1 .. 1, added last, after contrast float contrast = 0.f; // -1 .. 1, pivoted on mid grey; -1 flattens to grey, 1 doubles the slope float hue = 0.f; // -180 .. 180 degrees, rotated about the grey axis at constant luma + float sharpness = 0.f; // 0 .. 1 unsharp mask amount, 0 is off + // Line art and logos: everything lighter than `threshold` becomes white and the rest black, so the + // picture matches exactly two filaments instead of speckling across several. + bool threshold_enabled = false; + float threshold = 0.5f; // 0 .. 1, on Rec. 709 luma + // Leaves the background of a picture unprinted, so the object's own colour shows through instead + // of a slab of white behind a logo. Texels the source image marked transparent always count; + // `bg_remove` additionally keys out whatever colour the picture's corners share. + bool bg_remove = false; + float bg_tolerance = 0.15f; // 0 .. 1, distance from the keyed colour that still counts as background float saturation = 1.f; // 0 .. 2, 1 leaves it alone, 0 is grayscale float gamma = 1.f; // 0.2 .. 5, 1 leaves it alone; above 1 lifts the midtones bool color_invert = false; // negative of the colour, as opposed to `invert` which flips relief @@ -314,7 +324,8 @@ struct TextureDisplacementLayer static_cast(blend_mode), midlevel, island_padding_mm, lscm_seam_edges, view_project_right, view_project_up, smoothing, edge_smoothing, edge_smoothing_amount, auto_connect_islands, island_groups, lscm_uv_overrides, view_project_projective, view_project_matrix, color_enabled, brightness, - contrast, saturation, gamma, color_invert, hue); + contrast, saturation, gamma, color_invert, hue, sharpness, threshold_enabled, threshold, + bg_remove, bg_tolerance); } template void load(Archive &ar) { @@ -326,7 +337,8 @@ struct TextureDisplacementLayer tile_method_int, projection_method_int, lscm_seam_angle_deg, islands, blend_mode_int, midlevel, island_padding_mm, lscm_seam_edges, view_project_right, view_project_up, smoothing, edge_smoothing, edge_smoothing_amount, auto_connect_islands, island_groups, lscm_uv_overrides, view_project_projective, - view_project_matrix, color_enabled, brightness, contrast, saturation, gamma, color_invert, hue); + view_project_matrix, color_enabled, brightness, contrast, saturation, gamma, color_invert, hue, sharpness, + threshold_enabled, threshold, bg_remove, bg_tolerance); image_data = blob.empty() ? nullptr : std::make_shared>(blob.begin(), blob.end()); tile_method = static_cast(tile_method_int); projection_method = static_cast(projection_method_int); @@ -460,11 +472,19 @@ struct DecodedHeightTexture // has_color() is what the whole colour feature keys off: a layer set to colour a model with a // grayscale texture on it simply colours nothing. std::vector rgb; + // Coverage: 0 where the picture is not there at all, 255 where it is fully there. Empty when the + // source image had no alpha and nothing has keyed a background out, which is the common case and + // means "opaque everywhere". Distinct from a black texel, which is a colour. + std::vector alpha; int width = 0; int height = 0; bool empty() const { return width <= 0 || height <= 0 || pixels.empty(); } bool has_color() const { return !empty() && rgb.size() == size_t(width) * size_t(height) * 3; } + bool has_alpha() const { return !empty() && alpha.size() == size_t(width) * size_t(height); } + // Coverage at a uv, bilinear, in [0, 1]. 1 everywhere when there is no alpha plane. + float sample_alpha(const Vec2f &uv, bool tile_enabled = true, + TextureTileMethod tile_method = TextureTileMethod::Repeat) const; // Bilinearly sampled height in [0, 1] at a normalized uv coordinate. When tile_enabled is false, // a uv outside [0, 1) samples as 0 - the texture simply is not there, rather than its border // row/column being smeared outward forever (which is what clamping the coordinate would do, and diff --git a/src/slic3r/GUI/Gizmos/GLGizmoImage.cpp b/src/slic3r/GUI/Gizmos/GLGizmoImage.cpp index 30dbd07df9..10a57b6a5f 100644 --- a/src/slic3r/GUI/Gizmos/GLGizmoImage.cpp +++ b/src/slic3r/GUI/Gizmos/GLGizmoImage.cpp @@ -529,6 +529,35 @@ void GLGizmoTextureDisplacement::render_image_input_window(float x, float y, flo m_preview_params_dirty |= float_row("##gamma", _L("Gamma"), &layer->gamma, 0.2f, 5.f, "%.2f", true); hover_tip(_u8L("Lifts or drops the midtones without touching the brightest and darkest parts. Use it " "when the middle of the picture is the part coming out wrong.")); + m_preview_params_dirty |= float_row("##sharpen", _L("Sharpen"), &layer->sharpness, 0.f, 1.f, "%.2f", false); + hover_tip(_u8L("Crisps up the edges before the picture is matched to your filaments. Worth more here " + "than in a photo: a soft edge between two colors quantizes into a ragged band of " + "both, and sharpening first makes it land in one place.")); + + // Two filaments, on purpose. The case this exists for is a logo or line art, where anything + // in between the two reads as speckle rather than as shading. + if (m_imgui->bbl_checkbox(_L("Two colors"), layer->threshold_enabled)) + m_preview_params_dirty = true; + hover_tip(_u8L("Forces the picture to light and dark only, so it prints in exactly two filaments. " + "For a logo or line art, where the in-between grays come out as speckle.")); + if (layer->threshold_enabled) { + m_preview_params_dirty |= float_row("##threshold", _L("Split at"), &layer->threshold, 0.05f, 0.95f, "%.2f", false); + hover_tip(_u8L("Where the split falls. Lower keeps more of the picture on the light side, higher " + "on the dark one.")); + } + + if (m_imgui->bbl_checkbox(_L("Remove background"), layer->bg_remove)) + m_preview_params_dirty = true; + hover_tip(_u8L("Leaves the background unprinted, so the object's own color shows through instead of " + "a slab of white behind a logo. The color is taken from the picture's four corners. " + "A picture that is already transparent needs none of this: those parts are left out " + "either way.")); + if (layer->bg_remove) { + m_preview_params_dirty |= float_row("##bg_tol", _L("Tolerance"), &layer->bg_tolerance, 0.01f, 0.6f, "%.2f", false); + hover_tip(_u8L("How different from the corners still counts as background. Raise it when a " + "shadow or a gradient is left behind; lower it when it starts eating the picture.")); + } + if (m_imgui->bbl_checkbox(_L("Negative"), layer->color_invert)) m_preview_params_dirty = true; hover_tip(_u8L("Prints the picture inverted, light for dark and each color for its opposite.")); @@ -539,6 +568,11 @@ void GLGizmoTextureDisplacement::render_image_input_window(float x, float y, flo layer->saturation = 1.f; layer->hue = 0.f; layer->gamma = 1.f; + layer->sharpness = 0.f; + layer->threshold_enabled = false; + layer->threshold = 0.5f; + layer->bg_remove = false; + layer->bg_tolerance = 0.15f; layer->color_invert = false; m_preview_params_dirty = true; } diff --git a/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.cpp b/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.cpp index 41d1b3f600..eb744b3e5d 100644 --- a/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.cpp +++ b/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.cpp @@ -409,10 +409,18 @@ std::unique_ptr upload_color_texture(const DecodedHeightTexture &deco for (int c = 0; c < 3; ++c) sum[c] += decoded.rgb[si + size_t(c)]; } + // Coverage rides in the fourth channel, box-filtered with the colour, so the shader can + // leave the surface alone where the picture is not there (see the colour path in + // texture_displacement_shaded.fs). + unsigned int sum_a = 0; + if (decoded.has_alpha()) + for (int sy = y0; sy < y1 && sy < decoded.height; ++sy) + for (int sx = x0; sx < x1 && sx < decoded.width; ++sx) + sum_a += decoded.alpha[size_t(sy) * size_t(decoded.width) + size_t(sx)]; const size_t di = (size_t(y) * size_t(w) + size_t(x)) * 4; for (int c = 0; c < 3; ++c) rgba[di + size_t(c)] = (n > 0) ? static_cast(sum[size_t(c)] / n) : 0; - rgba[di + 3] = 255; + rgba[di + 3] = (decoded.has_alpha() && n > 0) ? static_cast(sum_a / n) : 255; } auto texture = std::make_unique(); diff --git a/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.hpp b/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.hpp index 2ef072ecdb..241976f50d 100644 --- a/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.hpp +++ b/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.hpp @@ -160,16 +160,21 @@ public: struct ColorAdjustKey { float brightness = 0.f, contrast = 0.f, saturation = 1.f, gamma = 1.f, hue = 0.f; - bool invert = false; + float sharpness = 0.f, threshold = 0.5f, bg_tolerance = 0.15f; + bool invert = false, threshold_on = false, bg_remove = false; bool operator==(const ColorAdjustKey &o) const { return brightness == o.brightness && contrast == o.contrast && saturation == o.saturation && - gamma == o.gamma && hue == o.hue && invert == o.invert; + gamma == o.gamma && hue == o.hue && sharpness == o.sharpness && + threshold == o.threshold && bg_tolerance == o.bg_tolerance && invert == o.invert && + threshold_on == o.threshold_on && bg_remove == o.bg_remove; } bool operator!=(const ColorAdjustKey &o) const { return !(*this == o); } static ColorAdjustKey of(const TextureDisplacementLayer &l) { - return { l.brightness, l.contrast, l.saturation, l.gamma, l.hue, l.color_invert }; + return { l.brightness, l.contrast, l.saturation, l.gamma, l.hue, + l.sharpness, l.threshold, l.bg_tolerance, + l.color_invert, l.threshold_enabled, l.bg_remove }; } }; std::vector m_palette_adjusts; // alongside m_palette_images, one per entry