Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions volume-cartographer/apps/src/vc_render_tifxyz.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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_<cv::Vec3f>& pts, const cv::Mat_<cv::Vec3f>& nrm,
float scale_seg, float ds_scale,
bool hasAffine, const AffineTransform& aff,
Expand All @@ -274,6 +281,7 @@ static void prepareBaseAndDirs(const cv::Mat_<cv::Vec3f>& pts, const cv::Mat_<cv
dirs = nrm.clone();
if (hasAffine) applyAffineTransform(base, dirs, aff);
normalizeNormals(dirs);
if (g_flipNormals) flip_surface_normals(dirs);
base *= ds_scale;
}

Expand Down Expand Up @@ -1063,6 +1071,7 @@ int main(int argc, char *argv[])
("scale-segmentation", po::value<float>()->default_value(1.0), "Scale segmentation")
("rotate", po::value<double>()->default_value(0.0), "Rotate output (0/90/180/270)")
("flip", po::value<int>()->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<std::string>(), "Output path for .zarr (optional)")
("tif-output", po::value<std::string>(), "Output path for per-slice TIFFs (optional)")
("quick-tif", po::bool_switch()->default_value(false), "Fast TIF: PACKBITS + zero low nibble")
Expand Down Expand Up @@ -1228,6 +1237,7 @@ int main(int argc, char *argv[])
float scale_seg = parsed["scale-segmentation"].as<float>();
double rotate_angle = parsed["rotate"].as<double>();
int flip_axis = parsed["flip"].as<int>();
g_flipNormals = parsed["flip-normals"].as<bool>();
const bool quickTif = parsed["quick-tif"].as<bool>();

// --- Load affines ---
Expand Down
7 changes: 7 additions & 0 deletions volume-cartographer/core/include/vc/core/util/Geometry.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,13 @@ cv::Vec3f grid_normal(const cv::Mat_<cv::Vec3f> &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_<cv::Vec3f> &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_<cv::Vec3f> &normals);


// Bilinear interpolation at fractional coordinates
cv::Vec3f at_int(const cv::Mat_<cv::Vec3f> &points, const cv::Vec2f &p);
Expand Down
16 changes: 16 additions & 0 deletions volume-cartographer/core/src/Geometry.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,22 @@ cv::Vec3f grid_normal(const cv::Mat_<cv::Vec3f> &points, const cv::Vec3f &loc)
return normed(n);
}

void flip_surface_normals(cv::Mat_<cv::Vec3f> &normals)
{
for (int y = 0; y < normals.rows; ++y) {
cv::Vec3f* row = normals.ptr<cv::Vec3f>(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 <typename E>
static E at_int_impl(const cv::Mat_<E> &points, const cv::Vec2f& p)
{
Expand Down
1 change: 1 addition & 0 deletions volume-cartographer/core/test/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
72 changes: 72 additions & 0 deletions volume-cartographer/core/test/test_flip_surface_normals.cpp
Original file line number Diff line number Diff line change
@@ -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 <doctest/doctest.h>

#include "vc/core/util/Geometry.hpp"

#include <opencv2/core.hpp>

#include <cmath>
#include <limits>

namespace {
const float QNAN = std::numeric_limits<float>::quiet_NaN();
}

TEST_CASE("flip_surface_normals negates every finite normal")
{
cv::Mat_<cv::Vec3f> 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_<cv::Vec3f> 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_<cv::Vec3f> 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_<cv::Vec3f> 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_<cv::Vec3f> n;
flip_surface_normals(n); // must not crash
CHECK(n.empty());
}