diff --git a/src/libslic3r/TextureDisplacement.cpp b/src/libslic3r/TextureDisplacement.cpp index ab63a75c70..93626fdbaf 100644 --- a/src/libslic3r/TextureDisplacement.cpp +++ b/src/libslic3r/TextureDisplacement.cpp @@ -692,7 +692,66 @@ TextureDetail analyze_texture_detail(const TextureDisplacementLayer &layer) for (int i = 0; i < 8; ++i) top += bins[size_t(i)]; out.flat_share = float(double(top) / double(npx)); - out.flat_colors = out.flat_share >= 0.85f; + + // How much of the principal colour axis carries pixels. Sampled rather than exhaustive: the + // axis is a bulk property, and a stride keeps this a few milliseconds on a large image. + { + constexpr size_t MAX_SAMPLES = size_t(1) << 16; + const size_t stride = std::max(1, npx / MAX_SAMPLES); + std::vector pts; + pts.reserve(npx / stride + 1); + for (size_t i = 0; i < npx; i += stride) + pts.push_back(tex.has_color() ? + Vec3f(float(tex.rgb[i * 3]), float(tex.rgb[i * 3 + 1]), float(tex.rgb[i * 3 + 2])) : + Vec3f::Constant(float(tex.pixels[i]))); + if (pts.size() >= 16) { + Vec3f mean = Vec3f::Zero(); + for (const Vec3f &p : pts) + mean += p; + mean /= float(pts.size()); + // Power iteration on the covariance: the axis, without forming the matrix. + Vec3f axis(1.f, 0.3f, 0.2f); + for (int it = 0; it < 24; ++it) { + Vec3f next = Vec3f::Zero(); + for (const Vec3f &p : pts) { + const Vec3f d = p - mean; + next += d.dot(axis) * d; + } + const float n = next.norm(); + if (n < 1e-6f) + break; + axis = next / n; + } + std::vector proj(pts.size()); + for (size_t i = 0; i < pts.size(); ++i) + proj[i] = (pts[i] - mean).dot(axis); + std::sort(proj.begin(), proj.end()); + // Trimmed, so a handful of stray pixels cannot stretch the range and make a flat + // image look like it spans one. + const float lo = proj[size_t(0.01 * double(proj.size()))]; + const float hi = proj[std::min(proj.size() - 1, size_t(0.99 * double(proj.size())))]; + const float span = hi - lo; + if (span > 1e-3f) { + constexpr int BUCKETS = 32; + std::array hist{}; + for (const float s : proj) + if (s >= lo && s <= hi) + ++hist[size_t(std::min(BUCKETS - 1, int((s - lo) / span * BUCKETS)))]; + // A bucket counts as carrying pixels at a fifth of a percent of them, which is + // above the noise a dithered image leaves between its colours. + const size_t floor_count = size_t(0.002 * double(proj.size())); + int filled = 0; + for (const size_t c : hist) + filled += c >= floor_count; + out.axis_coverage = float(filled) / float(BUCKETS); + } + } + } + + // Both signals, because either alone misreads a common case: the histogram calls a + // two-colour gradient flat (measured 0.90 on a green-to-red ramp, and 0.96 on a painted + // model texture), while coverage alone would call a noisy two-colour logo a gradient. + out.flat_colors = out.flat_share >= 0.85f && out.axis_coverage < 0.25f; } std::lock_guard lock(g_texture_detail_cache.mutex); auto &entries = g_texture_detail_cache.entries; diff --git a/src/libslic3r/TextureDisplacement.hpp b/src/libslic3r/TextureDisplacement.hpp index 6bd5d1ea33..e319515074 100644 --- a/src/libslic3r/TextureDisplacement.hpp +++ b/src/libslic3r/TextureDisplacement.hpp @@ -504,6 +504,11 @@ struct TextureDetail // verdict: a "flat-colour" image (tiles, logos, camouflage) whose colours should each print in a // single filament, versus a photograph or gradient where interleaved filament mixes pay off. float flat_share = 0.f; + // Share of the image's principal colour axis that actually carries pixels, in 32 steps. A ramp + // covers it end to end; a few flat colours sit in spikes with gaps between them. The histogram + // above cannot tell those apart - a two-colour gradient occupies as few bins as the two colours + // on their own - so both are needed before an image counts as flat. + float axis_coverage = 1.f; bool flat_colors = false; }; TextureDetail analyze_texture_detail(const TextureDisplacementLayer &layer); diff --git a/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.cpp b/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.cpp index 5e87169522..54f50c2a78 100644 --- a/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.cpp +++ b/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.cpp @@ -7390,7 +7390,8 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float const bool flat = std::any_of(mv->texture_displacement_layers.begin(), mv->texture_displacement_layers.end(), [](const TextureDisplacementLayer &l) { - return layer_shows_color(l) && + return l.color_enabled && !l.empty() && + height_texture_has_color(l) && analyze_texture_detail(l).flat_colors; }); ImGui::PushTextWrapPos(content_rx - card_pad); diff --git a/tests/libslic3r/test_texture_displacement.cpp b/tests/libslic3r/test_texture_displacement.cpp index 4c3e74d705..cfd134b461 100644 --- a/tests/libslic3r/test_texture_displacement.cpp +++ b/tests/libslic3r/test_texture_displacement.cpp @@ -94,6 +94,33 @@ static std::shared_ptr> make_flat_rgb_png(uint8_t r, return std::make_shared>(std::move(bytes)); } +// An RGB image built by a caller-supplied function of the pixel position, written through the same +// temp-file round trip the flat helpers above use. +static std::shared_ptr> make_rgb_png( + size_t w, size_t h, const std::function(size_t, size_t)> &at) +{ + std::vector rgb; + rgb.reserve(w * h * 3); + for (size_t y = 0; y < h; ++y) + for (size_t x = 0; x < w; ++x) { + const std::array c = at(x, y); + rgb.insert(rgb.end(), { c[0], c[1], c[2] }); + } + const boost::filesystem::path tmp_path = boost::filesystem::temp_directory_path() + / boost::filesystem::unique_path("texdisp_test_%%%%%%%%.png"); + REQUIRE(Slic3r::png::write_rgb_to_file(tmp_path.string(), w, h, rgb)); + + std::vector bytes; + { + std::ifstream ifs(tmp_path.string(), std::ios::binary); + bytes.assign(std::istreambuf_iterator(ifs), std::istreambuf_iterator()); + } + boost::system::error_code ec; + boost::filesystem::remove(tmp_path, ec); + REQUIRE_FALSE(bytes.empty()); + return std::make_shared>(std::move(bytes)); +} + // A hard-edged black/white checkerboard, the worst case for a height map: every texel boundary is a // step, which is precisely the relief the post-process smoothing exists to round off. static std::shared_ptr> make_checkerboard_png(size_t w = 16, size_t h = 16) @@ -2626,6 +2653,34 @@ TEST_CASE("TextureDisplacement: a texture of flat colours is told apart from a c CHECK(d.flat_share < 0.85f); } +TEST_CASE("A two-colour ramp is not mistaken for an image of flat colours", "[TextureDisplacement]") +{ + // The histogram alone cannot tell these two apart: a ramp between two colours visits as few + // coarse bins as the two colours on their own, and both score far above the flat-share + // threshold. What separates them is that the ramp fills its colour axis and the pair does not. + const std::array a{ 0x4f, 0xc3, 0x08 }; + const std::array b{ 0xd4, 0x2f, 0x25 }; + + TextureDisplacementLayer ramp; + ramp.image_data = make_rgb_png(96, 96, [&](size_t, size_t y) { + const float t = float(y) / 95.f; + return std::array{ uint8_t(std::lround(a[0] + (b[0] - a[0]) * t)), + uint8_t(std::lround(a[1] + (b[1] - a[1]) * t)), + uint8_t(std::lround(a[2] + (b[2] - a[2]) * t)) }; + }); + const TextureDetail ramp_detail = analyze_texture_detail(ramp); + CHECK(ramp_detail.flat_share >= 0.85f); // the old signal, on its own, calls this flat + CHECK(ramp_detail.axis_coverage > 0.8f); // but its colours run the length of the axis + CHECK_FALSE(ramp_detail.flat_colors); // so mixes stay available + + // The same two colours as areas rather than a ramp: nothing in between, so no mix would serve it. + TextureDisplacementLayer two_tone; + two_tone.image_data = make_rgb_png(96, 96, [&](size_t, size_t y) { return y < 48 ? a : b; }); + const TextureDetail two_tone_detail = analyze_texture_detail(two_tone); + CHECK(two_tone_detail.axis_coverage < 0.25f); + CHECK(two_tone_detail.flat_colors); +} + TEST_CASE("Each layer's texture is sampled only on its own painted area", "[TextureDisplacement]") { // Two layers with different textures and depths, one painted on the cube's top, one on its -X side.