diff --git a/volume-cartographer/apps/src/vc_render_tifxyz.cpp b/volume-cartographer/apps/src/vc_render_tifxyz.cpp index 77cdf9e814..b25c5dda7a 100644 --- a/volume-cartographer/apps/src/vc_render_tifxyz.cpp +++ b/volume-cartographer/apps/src/vc_render_tifxyz.cpp @@ -3,6 +3,7 @@ #include "vc/core/render/ZarrChunkFetcher.hpp" #include "vc/core/util/QuadSurface.hpp" #include "vc/core/util/Surface.hpp" +#include "vc/core/util/Geometry.hpp" #include "vc/core/util/Tiff.hpp" #include "vc/core/util/Zarr.hpp" #include "vc/core/util/StreamOperators.hpp" @@ -265,6 +266,12 @@ static void genTile(QuadSurface* surf, const cv::Size& size, float render_scale, surf->gen(&points, &normals, size, cv::Vec3f(0,0,0), render_scale, cv::Vec3f(u0, v0, 0)); } +// Set once from --flip-normals before rendering starts; read by every +// prepareBaseAndDirs call. grid_normal's right-handed normals point away from +// the scroll centre when text is readable, so renders that want the normal to +// point into the scroll enable this (see issue #1044). +static bool g_flipNormals = false; + static void prepareBaseAndDirs(const cv::Mat_& pts, const cv::Mat_& nrm, float scale_seg, float ds_scale, bool hasAffine, const AffineTransform& aff, @@ -274,6 +281,7 @@ static void prepareBaseAndDirs(const cv::Mat_& pts, const cv::Mat_()->default_value(1.0), "Scale segmentation") ("rotate", po::value()->default_value(0.0), "Rotate output (0/90/180/270)") ("flip", po::value()->default_value(-1), "Flip: 0=V, 1=H, 2=Both") + ("flip-normals", po::bool_switch()->default_value(false), "Negate surface normals so the offset (w) direction points the opposite way (e.g. into the scroll); see issue #1044") ("zarr-output", po::value(), "Output path for .zarr (optional)") ("tif-output", po::value(), "Output path for per-slice TIFFs (optional)") ("quick-tif", po::bool_switch()->default_value(false), "Fast TIF: PACKBITS + zero low nibble") @@ -1228,6 +1237,7 @@ int main(int argc, char *argv[]) float scale_seg = parsed["scale-segmentation"].as(); double rotate_angle = parsed["rotate"].as(); int flip_axis = parsed["flip"].as(); + g_flipNormals = parsed["flip-normals"].as(); const bool quickTif = parsed["quick-tif"].as(); // --- Load affines --- diff --git a/volume-cartographer/core/include/vc/core/util/Geometry.hpp b/volume-cartographer/core/include/vc/core/util/Geometry.hpp index 37829c43fe..4cc497b736 100644 --- a/volume-cartographer/core/include/vc/core/util/Geometry.hpp +++ b/volume-cartographer/core/include/vc/core/util/Geometry.hpp @@ -10,6 +10,13 @@ cv::Vec3f grid_normal(const cv::Mat_ &points, const cv::Vec3f &loc); // 1 <= row <= rows-2 and 1 <= col <= cols-2 (no clamping inside). cv::Vec3f grid_normal_int(const cv::Mat_ &points, int row, int col); +// Flip surface normals in place (negate each vector). grid_normal uses the +// right-handed convention N = dP/dU x dP/dV, which points away from the scroll +// centre when the text is readable; some renders need the opposite direction. +// Invalid normals (NaN sentinels, as produced by grid_normal) are left +// untouched so validity is preserved. +void flip_surface_normals(cv::Mat_ &normals); + // Bilinear interpolation at fractional coordinates cv::Vec3f at_int(const cv::Mat_ &points, const cv::Vec2f &p); diff --git a/volume-cartographer/core/src/Geometry.cpp b/volume-cartographer/core/src/Geometry.cpp index edfec27bec..fa426bfe34 100644 --- a/volume-cartographer/core/src/Geometry.cpp +++ b/volume-cartographer/core/src/Geometry.cpp @@ -100,6 +100,22 @@ cv::Vec3f grid_normal(const cv::Mat_ &points, const cv::Vec3f &loc) return normed(n); } +void flip_surface_normals(cv::Mat_ &normals) +{ + for (int y = 0; y < normals.rows; ++y) { + cv::Vec3f* row = normals.ptr(y); + for (int x = 0; x < normals.cols; ++x) { + cv::Vec3f& v = row[x]; + // Leave NaN sentinels (invalid normals) untouched so validity is + // preserved; this mirrors the renderer's normalizeNormals skip. + if (v[0] != v[0]) continue; + v[0] = -v[0]; + v[1] = -v[1]; + v[2] = -v[2]; + } + } +} + template static E at_int_impl(const cv::Mat_ &points, const cv::Vec2f& p) { diff --git a/volume-cartographer/core/test/CMakeLists.txt b/volume-cartographer/core/test/CMakeLists.txt index 8da0146b2b..75b49091d6 100644 --- a/volume-cartographer/core/test/CMakeLists.txt +++ b/volume-cartographer/core/test/CMakeLists.txt @@ -327,6 +327,7 @@ vc_add_test(NAME test_plane_surface) vc_add_test(NAME test_compositing) vc_add_test(NAME test_quadsurface_basics) +vc_add_test(NAME test_flip_surface_normals) vc_add_test(NAME test_load_json) diff --git a/volume-cartographer/core/test/test_flip_surface_normals.cpp b/volume-cartographer/core/test/test_flip_surface_normals.cpp new file mode 100644 index 0000000000..66d2fc51c0 --- /dev/null +++ b/volume-cartographer/core/test/test_flip_surface_normals.cpp @@ -0,0 +1,72 @@ +// Unit tests for flip_surface_normals (issue #1044 core helper). +// +// grid_normal uses the right-handed convention N = dP/dU x dP/dV, which points +// away from the scroll centre when text is readable. flip_surface_normals +// negates each normal so renders can point the offset (w) direction the other +// way, while leaving NaN-sentinel (invalid) normals untouched. + +#define DOCTEST_CONFIG_IMPLEMENT_WITH_MAIN +#include + +#include "vc/core/util/Geometry.hpp" + +#include + +#include +#include + +namespace { +const float QNAN = std::numeric_limits::quiet_NaN(); +} + +TEST_CASE("flip_surface_normals negates every finite normal") +{ + cv::Mat_ n(2, 2); + n(0, 0) = cv::Vec3f(1.f, 0.f, 0.f); + n(0, 1) = cv::Vec3f(0.f, -2.f, 3.f); + n(1, 0) = cv::Vec3f(-4.f, 5.f, -6.f); + n(1, 1) = cv::Vec3f(0.f, 0.f, 1.f); + + flip_surface_normals(n); + + CHECK(n(0, 0) == cv::Vec3f(-1.f, 0.f, 0.f)); + CHECK(n(0, 1) == cv::Vec3f(0.f, 2.f, -3.f)); + CHECK(n(1, 0) == cv::Vec3f(4.f, -5.f, 6.f)); + CHECK(n(1, 1) == cv::Vec3f(0.f, 0.f, -1.f)); +} + +TEST_CASE("flip_surface_normals is its own inverse (applied twice = identity)") +{ + cv::Mat_ n(1, 3); + n(0, 0) = cv::Vec3f(0.37f, -1.5f, 2.25f); + n(0, 1) = cv::Vec3f(-9.f, 8.f, -7.f); + n(0, 2) = cv::Vec3f(0.f, 0.f, 0.f); + const cv::Mat_ orig = n.clone(); + + flip_surface_normals(n); + flip_surface_normals(n); + + for (int x = 0; x < n.cols; ++x) + CHECK(n(0, x) == orig(0, x)); +} + +TEST_CASE("flip_surface_normals leaves NaN-sentinel normals untouched") +{ + cv::Mat_ n(1, 2); + n(0, 0) = cv::Vec3f(QNAN, QNAN, QNAN); + n(0, 1) = cv::Vec3f(1.f, 2.f, 3.f); + + flip_surface_normals(n); + + CHECK(std::isnan(n(0, 0)[0])); + CHECK(std::isnan(n(0, 0)[1])); + CHECK(std::isnan(n(0, 0)[2])); + CHECK(n(0, 1) == cv::Vec3f(-1.f, -2.f, -3.f)); +} + +TEST_CASE("flip_surface_normals on an empty matrix is a no-op") +{ + cv::Mat_ n; + flip_surface_normals(n); // must not crash + CHECK(n.empty()); +}