From 235aa5409b39f5b7ae858a5c2f62e2774050b70e Mon Sep 17 00:00:00 2001 From: Seth Parker Date: Thu, 9 Jul 2026 09:22:22 -0400 Subject: [PATCH 1/3] Support multiple texture images per mesh MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Change ReorderUnorganizedTexture::setTextureMat → setTextureMats (vector-based) - Normalize each input texture to 8-bit, 3-channel internally via QuantizeImage + ColorConvertImage - Implement chart-aware texture sampling: each face samples from its UV chart's image - Update MeshReadResult: single texture/texturePath → vectors textures/texturePaths - ReadMesh now loads all referenced textures, preserving chart-index alignment - Add resolve_chart_image_() helper to locate chart texture; report_missing_charts_() warns for unused charts - Update ComputeUVDensity to handle multi-chart meshes with varying image dimensions - Remove ColorConvertNode from ReorderTexture app (normalization now internal) - Graph layer: MeshReadNode exposes images vector; ReorderTextureNode input port imageIn → imagesIn - Add unit tests for multi-chart UV-mapped meshes (camera-path tests to avoid vtkOBBTree degenerate-mesh issue) Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/src/ReorderTexture.cpp | 10 +- apps/src/SeamFlattening.cpp | 2 +- apps/src/TextureDewarp.cpp | 2 +- core/include/rt/ReorderUnorganizedTexture.hpp | 53 +++++-- core/include/rt/io/MeshIO.hpp | 15 +- core/src/MeshIO.cpp | 16 +- core/src/ReorderUnorganizedTexture.cpp | 125 +++++++++++---- graph/include/rt/graph/MeshIO.hpp | 9 +- graph/include/rt/graph/MeshOps.hpp | 5 +- graph/src/MeshIO.cpp | 9 +- graph/src/MeshOps.cpp | 4 +- tests/src/TestReorderUnorganizedTexture.cpp | 146 ++++++++++++++++++ 12 files changed, 327 insertions(+), 69 deletions(-) diff --git a/apps/src/ReorderTexture.cpp b/apps/src/ReorderTexture.cpp index 740d2a2..18ddb25 100644 --- a/apps/src/ReorderTexture.cpp +++ b/apps/src/ReorderTexture.cpp @@ -335,16 +335,12 @@ auto main(int argc, char* argv[]) -> int auto reader = graph.insertNode(); reader->path = inputPath; - // We don't support RGBA textures - auto convert = graph.insertNode(); - convert->imageIn = reader->image; - convert->channels = 3; - - // Reorder the texture + // Reorder the texture. ReorderUnorganizedTexture normalizes each input + // image to 8-bit, 3-channel internally, so no ColorConvertNode is needed. auto reorder = graph.insertNode(); reorder->meshIn = reader->mesh; reorder->uvMapIn = reader->uvMap; - reorder->imageIn = convert->imageOut; + reorder->imagesIn = reader->images; reorder->samplingOrigin = samplingOrigin; reorder->samplingMode = sampleMode; reorder->sampleRate = sampleRate; diff --git a/apps/src/SeamFlattening.cpp b/apps/src/SeamFlattening.cpp index caa9423..350f84b 100644 --- a/apps/src/SeamFlattening.cpp +++ b/apps/src/SeamFlattening.cpp @@ -364,7 +364,7 @@ auto main(int argc, const char* argv[]) -> int ReorderUnorganizedTexture reorder; reorder.setMesh(flat); reorder.setUVMap(reader.uvMap); - reorder.setTextureMat(reader.texture); + reorder.setTextureMats(reader.textures); reorder.setSamplingMode(ReorderUnorganizedTexture::SamplingMode::AutoUV); const auto texture = reorder.compute(); diff --git a/apps/src/TextureDewarp.cpp b/apps/src/TextureDewarp.cpp index d195ade..f0183a0 100644 --- a/apps/src/TextureDewarp.cpp +++ b/apps/src/TextureDewarp.cpp @@ -128,7 +128,7 @@ auto main(int argc, const char* argv[]) -> int ReorderUnorganizedTexture reorder; reorder.setMesh(flat); reorder.setUVMap(reader.uvMap); - reorder.setTextureMat(reader.texture); + reorder.setTextureMats(reader.textures); reorder.setSamplingMode(ReorderUnorganizedTexture::SamplingMode::AutoUV); const auto texture = reorder.compute(); diff --git a/core/include/rt/ReorderUnorganizedTexture.hpp b/core/include/rt/ReorderUnorganizedTexture.hpp index 7ca410d..79a4a4c 100644 --- a/core/include/rt/ReorderUnorganizedTexture.hpp +++ b/core/include/rt/ReorderUnorganizedTexture.hpp @@ -4,6 +4,7 @@ #include #include +#include #include @@ -107,8 +108,27 @@ class ReorderUnorganizedTexture void setMesh(const Mesh::Pointer& mesh); /** @brief Set the input UV map for the mesh */ void setUVMap(const UVMap& uv); - /** @brief Set the input, unorganized texture image */ - void setTextureMat(const cv::Mat& img); + + /** + * @brief Set the input, unorganized texture images + * + * The mesh may be textured by more than one image (a multi-chart UV map, + * e.g. a multi-material OBJ). Images are indexed by UV chart: the color for + * a face is sampled from `imgs[chart]`, where `chart` is the atlas chart + * index carried by the face's UV coordinates (see rt::UVMap). A + * single-texture mesh is simply the one-element case (all faces chart 0). + * + * Faces whose chart has no corresponding image (chart index out of range or + * an empty `cv::Mat`) are left uncolored in the output; compute() emits a + * single warning naming the affected chart(s). + * + * @note Each image is normalized to 8-bit, 3-channel (BGR) on input via + * rt::QuantizeImage + rt::ColorConvertImage. Higher bit depths and other + * channel layouts are not yet preserved through the reorder pipeline; see + * https://github.com/educelab/registration-toolkit/issues/19 for the + * tracking issue on native multi-bit-depth/channel support. + */ + void setTextureMats(const std::vector& imgs); /** @copydoc samplingOrigin() */ void setSamplingOrigin(SamplingOrigin o); @@ -193,9 +213,6 @@ class ReorderUnorganizedTexture /** @brief Get the output UV map */ auto getUVMap() -> UVMap; - /** @brief Get the output texture image */ - auto getTextureMat() -> cv::Mat; - /** * @brief Get depth map * @@ -225,19 +242,33 @@ class ReorderUnorganizedTexture void create_texture_camera_(); /** - * Bilinearly sample the input texture color for a ray hit on face @p cellId - * with barycentric intersection (@p interU, @p interV). Assumes the input - * texture is non-empty. + * Resolve the input texture image a face samples from. Returns the image + * for the face's UV chart, or nullptr if that chart has no usable image + * (chart index out of range or an empty image); the offending chart index + * is recorded in missingCharts_ for a single aggregated warning. + */ + [[nodiscard]] auto resolve_chart_image_(std::size_t cellId) const + -> const cv::Mat*; + + /** + * Bilinearly sample @p img for a ray hit on face @p cellId with barycentric + * intersection (@p interU, @p interV). Assumes @p img is non-empty. */ [[nodiscard]] auto sample_surface_color_( - std::size_t cellId, double interU, double interV) const -> cv::Vec3b; + const cv::Mat& img, std::size_t cellId, double interU, double interV) + const -> cv::Vec3b; + + /** Emit one aggregated warning for charts with no usable image, if any */ + void report_missing_charts_() const; /** Input mesh */ Mesh::Pointer inputMesh_; /** Input UV map */ UVMap inputUV_; - /** Input texture image */ - cv::Mat inputTexture_; + /** Input texture images, indexed by UV chart */ + std::vector inputTextures_; + /** Chart indices encountered with no usable image (for warning) */ + mutable std::vector missingCharts_; /** Sample origin */ SamplingOrigin sampleOrigin_{SamplingOrigin::TopLeft}; diff --git a/core/include/rt/io/MeshIO.hpp b/core/include/rt/io/MeshIO.hpp index e42b354..d72f1b7 100644 --- a/core/include/rt/io/MeshIO.hpp +++ b/core/include/rt/io/MeshIO.hpp @@ -3,6 +3,7 @@ /** @file */ #include +#include #include @@ -18,10 +19,16 @@ struct MeshReadResult { Mesh::Pointer mesh; /** Loaded UV map (empty if the file had no texture coordinates) */ UVMap uvMap; - /** Loaded texture image (empty if no texture was referenced/found) */ - cv::Mat texture; - /** Resolved path to the texture image (empty if none) */ - std::filesystem::path texturePath; + /** + * Loaded texture images, one per referenced material, indexed by UV chart. + * A referenced-but-missing image is kept as an empty `cv::Mat` so the vector + * stays aligned with the UV map's chart indices (chart i ↔ textures[i]). + * Empty when the file references no textures. Consumers that expect a single + * texture should use `textures.front()` (guarding on `textures.empty()`). + */ + std::vector textures; + /** Resolved paths to the texture images, aligned with @ref textures */ + std::vector texturePaths; }; /** diff --git a/core/src/MeshIO.cpp b/core/src/MeshIO.cpp index 0cb70af..eab33dc 100644 --- a/core/src/MeshIO.cpp +++ b/core/src/MeshIO.cpp @@ -104,15 +104,21 @@ auto rt::ReadMesh(const fs::path& path) -> rt::MeshReadResult // Flip v to the in-memory top-left invariant result.uvMap = FlipV(result.uvMap); - // Resolve and load the first referenced texture, if any - if (not texturePaths.empty()) { - fs::path texPath = path.parent_path() / texturePaths.front().string(); + // Resolve and load every referenced texture, keeping the vectors aligned + // with the UV map's chart indices (chart i ↔ textures[i]). A missing image + // is stored as an empty cv::Mat / empty path so alignment is preserved. + result.textures.reserve(texturePaths.size()); + result.texturePaths.reserve(texturePaths.size()); + for (const auto& rel : texturePaths) { + fs::path texPath = path.parent_path() / rel.string(); if (fs::exists(texPath)) { - result.texturePath = texPath; - result.texture = rt::ReadImage(texPath); + result.texturePaths.push_back(texPath); + result.textures.push_back(rt::ReadImage(texPath)); } else { rt::logger()->warn( "Referenced texture not found: {}", texPath.string()); + result.texturePaths.emplace_back(); + result.textures.emplace_back(); } } diff --git a/core/src/ReorderUnorganizedTexture.cpp b/core/src/ReorderUnorganizedTexture.cpp index 21a2a92..a660cc4 100644 --- a/core/src/ReorderUnorganizedTexture.cpp +++ b/core/src/ReorderUnorganizedTexture.cpp @@ -6,6 +6,7 @@ #include #include #include +#include #include #include @@ -26,6 +27,7 @@ #include "rt/Logging.hpp" #include "rt/types/MeshToVTK.hpp" +#include "rt/util/ImageConversion.hpp" using Scalar = double; using Vector3 = bvh::v2::Vec; @@ -112,19 +114,17 @@ auto NearZero(T val, T eps = 1e-7) -> bool return std::abs(val) <= eps; } -// Calculate the pixel density of the UV map +// Calculate the average pixel density of the UV map. Each face's UVs are scaled +// by the dimensions of the texture image for its chart, so a multi-chart mesh +// with differently-sized textures contributes each region at its own density. auto ComputeUVDensity( const rt::Mesh& mesh, const rt::UVMap& uv, - const double imgWidth, - const double imgHeight) -> double + const std::vector& imgs) -> double { double density{0}; std::size_t count{0}; - auto maxXIdx = imgWidth - 1; - auto maxYIdx = imgHeight - 1; - // For each face for (std::size_t fi = 0; fi < mesh.num_faces(); ++fi) { const auto& face = mesh.face(fi); @@ -137,6 +137,14 @@ auto ComputeUVDensity( continue; } + // Skip faces whose chart has no usable image + const auto chart = uv.get_coordinate(fi, 0).chart; + if (chart >= imgs.size() or imgs[chart].empty()) { + continue; + } + const auto maxXIdx = imgs[chart].cols - 1.0; + const auto maxYIdx = imgs[chart].rows - 1.0; + // Get the 3D vertices std::array pts; for (std::size_t k = 0; k < 3; ++k) { @@ -519,9 +527,22 @@ void ReorderUnorganizedTexture::setMesh(const Mesh::Pointer& mesh) void ReorderUnorganizedTexture::setUVMap(const rt::UVMap& uv) { inputUV_ = uv; } -void ReorderUnorganizedTexture::setTextureMat(const cv::Mat& img) +void ReorderUnorganizedTexture::setTextureMats(const std::vector& imgs) { - inputTexture_ = img; + // Normalize each image to 8-bit, 3-channel (BGR). Empty images are kept in + // place so the vector stays indexable by UV chart. See setTextureMats() docs + // for the bit-depth/channel-support caveat. + inputTextures_.clear(); + inputTextures_.reserve(imgs.size()); + for (const auto& img : imgs) { + if (img.empty()) { + inputTextures_.emplace_back(); + continue; + } + auto out = rt::QuantizeImage(img, CV_8U); + out = rt::ColorConvertImage(out, 3); + inputTextures_.push_back(std::move(out)); + } } void ReorderUnorganizedTexture::setSamplingOrigin(const SamplingOrigin o) @@ -676,11 +697,6 @@ auto rt::UndistortNormalized( auto ReorderUnorganizedTexture::getUVMap() -> rt::UVMap { return outputUV_; } -auto ReorderUnorganizedTexture::getTextureMat() -> cv::Mat -{ - return outputTexture_; -} - auto ReorderUnorganizedTexture::getDepthMap() -> cv::Mat { return outputDepthMap_; @@ -785,8 +801,8 @@ void ReorderUnorganizedTexture::create_texture_() rows = static_cast(sampleDim_); break; case SamplingMode::AutoUV: - sampleRate = ::ComputeUVDensity( - *inputMesh_, inputUV_, inputTexture_.cols, inputTexture_.rows); + sampleRate = + ::ComputeUVDensity(*inputMesh_, inputUV_, inputTextures_); cols = static_cast(std::ceil(xLen / sampleRate)); rows = static_cast(std::ceil(yLen / sampleRate)); break; @@ -834,7 +850,10 @@ void ReorderUnorganizedTexture::create_texture_() break; } - const bool haveTexture = not inputTexture_.empty(); + const bool haveTexture = std::any_of( + inputTextures_.begin(), inputTextures_.end(), + [](const cv::Mat& m) { return not m.empty(); }); + missingCharts_.clear(); for (auto [v, u] : range2D(rows, cols)) { // Sample through the pixel center to avoid a half-pixel bias auto uOffset = (u + 0.5) * sampleRate * normedX; @@ -870,11 +889,14 @@ void ReorderUnorganizedTexture::create_texture_() // Sample the surface color into the output texture if (haveTexture) { const auto cellId = bvh.prim_ids[hit.value().primitiveIdx]; - const auto inter = hit.value().intersection; - outputTexture_.at(v, u) = - sample_surface_color_(cellId, inter.u, inter.v); + if (const auto* img = resolve_chart_image_(cellId)) { + const auto inter = hit.value().intersection; + outputTexture_.at(v, u) = + sample_surface_color_(*img, cellId, inter.u, inter.v); + } } } + report_missing_charts_(); outputUV_ = CreateUVMap(mesh, origin, xAxis, yAxis); } @@ -886,10 +908,12 @@ void ReorderUnorganizedTexture::create_texture_camera_() // Resolve camera parameters (auto-derive if not explicitly provided). The // auto camera is sized to preserve the input texture's pixel density. + const bool haveTexture = std::any_of( + inputTextures_.begin(), inputTextures_.end(), + [](const cv::Mat& m) { return not m.empty(); }); double texDensity{0.0}; - if (not inputTexture_.empty()) { - texDensity = ::ComputeUVDensity( - *inputMesh_, inputUV_, inputTexture_.cols, inputTexture_.rows); + if (haveTexture) { + texDensity = ::ComputeUVDensity(*inputMesh_, inputUV_, inputTextures_); } const ProjectionParams cam = projParamsSet_ ? projParams_ : ::AutoCamera(mesh, texDensity); @@ -931,7 +955,7 @@ void ReorderUnorganizedTexture::create_texture_camera_() const auto diag = cv::norm(bbMax - bbMin); const auto far = (cv::norm(camCenter - 0.5 * (bbMin + bbMax)) + diag) * 2.0; - const bool haveTexture = not inputTexture_.empty(); + missingCharts_.clear(); for (auto [v, u] : range2D(rows, cols)) { // Pinhole ray through the pixel center: dir = R^-1 * K^-1 * [u, v, 1]. // Undistort the normalized coords first so the ray matches the ideal @@ -967,18 +991,34 @@ void ReorderUnorganizedTexture::create_texture_camera_() // Sample the surface color into the output texture if (haveTexture) { const auto cellId = bvh.prim_ids[hit.value().primitiveIdx]; - const auto inter = hit.value().intersection; - outputTexture_.at(v, u) = - sample_surface_color_(cellId, inter.u, inter.v); + if (const auto* img = resolve_chart_image_(cellId)) { + const auto inter = hit.value().intersection; + outputTexture_.at(v, u) = + sample_surface_color_(*img, cellId, inter.u, inter.v); + } } } + report_missing_charts_(); outputUV_ = ::CreateProjectiveUVMap(mesh, cam); } +auto ReorderUnorganizedTexture::resolve_chart_image_( + const std::size_t cellId) const -> const cv::Mat* +{ + // The face's texture is the chart carried by its corner-0 UV coordinate, + // matching how libcore groups faces by chart on write. + const auto chart = inputUV_.get_coordinate(cellId, 0).chart; + if (chart >= inputTextures_.size() or inputTextures_[chart].empty()) { + missingCharts_.push_back(chart); + return nullptr; + } + return &inputTextures_[chart]; +} + auto ReorderUnorganizedTexture::sample_surface_color_( - const std::size_t cellId, const double interU, const double interV) const - -> cv::Vec3b + const cv::Mat& img, const std::size_t cellId, const double interU, + const double interV) const -> cv::Vec3b { // Precondition: reorder UV maps map every corner of every triangle. Both // CreateUVMap and CreateProjectiveUVMap insert one coordinate per cell @@ -1001,12 +1041,33 @@ auto ReorderUnorganizedTexture::sample_surface_color_( const cv::Vec3d bCoord{interU, interV, 1 - interU - interV}; const auto cPoint = ::BaryToXYZ(bCoord, uvPts[1], uvPts[2], uvPts[0]); - // Convert the UV position to pixel coordinates (in orig image) - const auto x = static_cast(cPoint[0] * (inputTexture_.cols - 1)); - const auto y = static_cast(cPoint[1] * (inputTexture_.rows - 1)); + // Convert the UV position to pixel coordinates (in the chart's image) + const auto x = static_cast(cPoint[0] * (img.cols - 1)); + const auto y = static_cast(cPoint[1] * (img.rows - 1)); // Bilinear interpolate color cv::Mat subRect; - cv::getRectSubPix(inputTexture_, {1, 1}, {x, y}, subRect); + cv::getRectSubPix(img, {1, 1}, {x, y}, subRect); return subRect.at(0, 0); } + +void ReorderUnorganizedTexture::report_missing_charts_() const +{ + if (missingCharts_.empty()) { + return; + } + std::sort(missingCharts_.begin(), missingCharts_.end()); + missingCharts_.erase( + std::unique(missingCharts_.begin(), missingCharts_.end()), + missingCharts_.end()); + + std::string list; + for (std::size_t i = 0; i < missingCharts_.size(); ++i) { + list += (i == 0 ? "" : ", ") + std::to_string(missingCharts_[i]); + } + logger()->warn( + "No usable texture image for UV chart(s) {}; those surface regions were " + "left uncolored in the output texture", + list); + missingCharts_.clear(); +} diff --git a/graph/include/rt/graph/MeshIO.hpp b/graph/include/rt/graph/MeshIO.hpp index 797f5ea..c8f8f8e 100644 --- a/graph/include/rt/graph/MeshIO.hpp +++ b/graph/include/rt/graph/MeshIO.hpp @@ -3,6 +3,7 @@ /** @file */ #include +#include #include #include @@ -34,10 +35,12 @@ class MeshReadNode : public smgl::Node /**@{*/ /** @brief Loaded mesh port */ smgl::OutputPort mesh{&mesh_}; - /** @brief Loaded image port */ + /** @brief First loaded image port (convenience; equals images[0]) */ smgl::OutputPort image{&img_}; /** @brief Loaded image path port */ smgl::OutputPort imagePath{&imgPath_}; + /** @brief All loaded texture images, indexed by UV chart */ + smgl::OutputPort> images{&imgs_}; /** @brief Load UV Map port */ smgl::OutputPort uvMap{&uv_}; /**@}*/ @@ -47,10 +50,12 @@ class MeshReadNode : public smgl::Node std::filesystem::path path_; /** Loaded mesh */ Mesh::Pointer mesh_; - /** Loaded image */ + /** First loaded image */ cv::Mat img_; /** Loaded image path */ std::filesystem::path imgPath_; + /** All loaded texture images, indexed by UV chart */ + std::vector imgs_; /** Loaded UV map */ UVMap uv_; /** Graph serialize */ diff --git a/graph/include/rt/graph/MeshOps.hpp b/graph/include/rt/graph/MeshOps.hpp index b04187f..436073f 100644 --- a/graph/include/rt/graph/MeshOps.hpp +++ b/graph/include/rt/graph/MeshOps.hpp @@ -1,6 +1,7 @@ #pragma once #include +#include #include #include @@ -35,8 +36,8 @@ class ReorderTextureNode : public smgl::Node /**@{*/ /** @brief Mesh port */ smgl::InputPort meshIn; - /** @brief Input texture image port */ - smgl::InputPort imageIn; + /** @brief Input texture images port (indexed by UV chart) */ + smgl::InputPort> imagesIn; /** @brief Input UV Map port */ smgl::InputPort uvMapIn; /** @copydoc ReorderUnorganizedTexture::samplingOrigin() */ diff --git a/graph/src/MeshIO.cpp b/graph/src/MeshIO.cpp index 7d6dd7e..8c6ad2f 100644 --- a/graph/src/MeshIO.cpp +++ b/graph/src/MeshIO.cpp @@ -17,14 +17,19 @@ rtg::MeshReadNode::MeshReadNode() registerOutputPort("mesh", mesh); registerOutputPort("image", image); registerOutputPort("imagePath", imagePath); + registerOutputPort("images", images); registerOutputPort("uvMap", uvMap); compute = [this]() { rt::logger()->info("Reading mesh: {}", path_.string()); auto result = ReadMesh(path_); mesh_ = result.mesh; - img_ = result.texture; - imgPath_ = result.texturePath; + imgs_ = result.textures; uv_ = result.uvMap; + // The single image/imagePath ports expose the first texture (chart 0) + // for downstream single-image consumers (e.g. registration). + img_ = imgs_.empty() ? cv::Mat() : imgs_.front(); + imgPath_ = result.texturePaths.empty() ? std::filesystem::path() + : result.texturePaths.front(); }; } diff --git a/graph/src/MeshOps.cpp b/graph/src/MeshOps.cpp index 06d31dd..1ed7df2 100644 --- a/graph/src/MeshOps.cpp +++ b/graph/src/MeshOps.cpp @@ -74,7 +74,7 @@ void from_json(const Json& j, ProjectionParams& p) rtg::ReorderTextureNode::ReorderTextureNode() : Node{true} , meshIn{&reorder_, &ReorderUnorganizedTexture::setMesh} - , imageIn{&reorder_, &ReorderUnorganizedTexture::setTextureMat} + , imagesIn{&reorder_, &ReorderUnorganizedTexture::setTextureMats} , uvMapIn{&reorder_, &ReorderUnorganizedTexture::setUVMap} , samplingOrigin{&reorder_, &ReorderUnorganizedTexture::setSamplingOrigin} , samplingMode{&reorder_, &ReorderUnorganizedTexture::setSamplingMode} @@ -92,7 +92,7 @@ rtg::ReorderTextureNode::ReorderTextureNode() , positionMapOut{&outPosition_} { registerInputPort("mesh", meshIn); - registerInputPort("imageIn", imageIn); + registerInputPort("imagesIn", imagesIn); registerInputPort("uvMapIn", uvMapIn); registerInputPort("samplingOrigin", samplingOrigin); registerInputPort("samplingMode", samplingMode); diff --git a/tests/src/TestReorderUnorganizedTexture.cpp b/tests/src/TestReorderUnorganizedTexture.cpp index bce9c15..b9400a7 100644 --- a/tests/src/TestReorderUnorganizedTexture.cpp +++ b/tests/src/TestReorderUnorganizedTexture.cpp @@ -1,9 +1,16 @@ +#include #include +#include #include +#include +#include #include +#include #include "rt/ReorderUnorganizedTexture.hpp" +#include "rt/types/Mesh.hpp" +#include "rt/types/UVMap.hpp" using ProjectionParams = rt::ReorderUnorganizedTexture::ProjectionParams; @@ -138,3 +145,142 @@ TEST(RadialDistortion, UndistortInvertsDistort) } } } + +namespace +{ +// A flat 2-quad sheet (x: 0..2, y: 0..1) in the z=0 plane. Faces 0,1 (left +// quad) are UV chart 0; faces 2,3 (right quad) are chart 1. UVs are all +// (0.5, 0.5): for a solid-color source image the exact UV is irrelevant, only +// the chart index matters. Sampled via an explicit top-down camera (see +// TopDownCamera) so the test does not depend on the OBB estimation used by the +// orthographic path, which produces an invalid output size for a small +// degenerate mesh like this one (see issue #20). +struct TwoChartMesh { + rt::Mesh::Pointer mesh; + rt::UVMap uv; +}; + +auto MakeTwoChartMesh() -> TwoChartMesh +{ + auto mesh = rt::Mesh::New(); + mesh->insert_vertex(0.0, 0.0, 0.0); // 0 + mesh->insert_vertex(1.0, 0.0, 0.0); // 1 + mesh->insert_vertex(1.0, 1.0, 0.0); // 2 + mesh->insert_vertex(0.0, 1.0, 0.0); // 3 + mesh->insert_vertex(2.0, 0.0, 0.0); // 4 + mesh->insert_vertex(2.0, 1.0, 0.0); // 5 + + mesh->insert_face(0, 1, 2); // face 0, left quad + mesh->insert_face(0, 2, 3); // face 1, left quad + mesh->insert_face(1, 4, 5); // face 2, right quad + mesh->insert_face(1, 5, 2); // face 3, right quad + + const std::array faceChart{0, 0, 1, 1}; + rt::UVMap uv; + for (std::size_t fi = 0; fi < faceChart.size(); ++fi) { + for (std::size_t corner = 0; corner < 3; ++corner) { + const auto idx = uv.insert(0.5F, 0.5F); + uv.at(idx).chart = faceChart[fi]; + uv.map(fi, corner, idx); + } + } + return {mesh, uv}; +} + +// A pinhole camera at world (1, 0.5, 1) looking straight down onto the z=0 +// sheet (OpenCV convention: +Z_cam forward into the scene, +Y_cam down). The +// sheet fills the 40x20 output. Bypasses the OBB-based orthographic path. +auto TopDownCamera() -> rt::ReorderUnorganizedTexture::ProjectionParams +{ + rt::ReorderUnorganizedTexture::ProjectionParams p; + p.fx = 20.0; + p.fy = 20.0; + p.cx = 20.0; + p.cy = 10.0; + p.width = 40; + p.height = 20; + // world->camera: R rows are the camera axes in world; t = -R * C. + // R = [[1,0,0],[0,-1,0],[0,0,-1]], C = (1, 0.5, 1) -> t = (-1, 0.5, 1). + p.extrinsics = cv::Matx44d::eye(); + p.extrinsics(1, 1) = -1.0; + p.extrinsics(2, 2) = -1.0; + p.extrinsics(0, 3) = -1.0; + p.extrinsics(1, 3) = 0.5; + p.extrinsics(2, 3) = 1.0; + return p; +} + +// Distinct non-black (non-background) colors present in a BGR image. +auto DistinctColors(const cv::Mat& img) -> std::set> +{ + std::set> colors; + for (int r = 0; r < img.rows; ++r) { + for (int c = 0; c < img.cols; ++c) { + const auto px = img.at(r, c); + if (px[0] == 0 and px[1] == 0 and px[2] == 0) { + continue; + } + colors.insert({px[0], px[1], px[2]}); + } + } + return colors; +} + +auto HasBackground(const cv::Mat& img) -> bool +{ + for (int r = 0; r < img.rows; ++r) { + for (int c = 0; c < img.cols; ++c) { + const auto px = img.at(r, c); + if (px[0] == 0 and px[1] == 0 and px[2] == 0) { + return true; + } + } + } + return false; +} +} // namespace + +TEST(ReorderMultiTexture, SamplesEachChartFromItsImage) +{ + const auto data = MakeTwoChartMesh(); + const cv::Mat redImg(16, 16, CV_8UC3, cv::Scalar(0, 0, 255)); // BGR red + const cv::Mat blueImg(16, 16, CV_8UC3, cv::Scalar(255, 0, 0)); // BGR blue + + rt::ReorderUnorganizedTexture reorder; + reorder.setMesh(data.mesh); + reorder.setUVMap(data.uv); + reorder.setTextureMats({redImg, blueImg}); + reorder.setProjectionMode( + rt::ReorderUnorganizedTexture::ProjectionMode::Camera); + reorder.setProjectionParams(TopDownCamera()); + const auto out = reorder.compute(); + + ASSERT_FALSE(out.empty()); + const auto colors = DistinctColors(out); + // Every colored pixel is exactly RED or BLUE, and both charts contributed. + EXPECT_EQ(colors.size(), 2U); + EXPECT_EQ(colors.count({0, 0, 255}), 1U); + EXPECT_EQ(colors.count({255, 0, 0}), 1U); +} + +TEST(ReorderMultiTexture, SkipsFacesWhoseChartHasNoImage) +{ + const auto data = MakeTwoChartMesh(); + const cv::Mat redImg(16, 16, CV_8UC3, cv::Scalar(0, 0, 255)); // BGR red + + rt::ReorderUnorganizedTexture reorder; + reorder.setMesh(data.mesh); + reorder.setUVMap(data.uv); + reorder.setTextureMats({redImg}); // no image supplied for chart 1 + reorder.setProjectionMode( + rt::ReorderUnorganizedTexture::ProjectionMode::Camera); + reorder.setProjectionParams(TopDownCamera()); + const auto out = reorder.compute(); + + ASSERT_FALSE(out.empty()); + const auto colors = DistinctColors(out); + // Only chart 0 (RED) is colored; chart-1 faces are left as background. + EXPECT_EQ(colors.size(), 1U); + EXPECT_EQ(colors.count({0, 0, 255}), 1U); + EXPECT_TRUE(HasBackground(out)); +} From 672d242e67a75cd700d333e59b31350802676d66 Mon Sep 17 00:00:00 2001 From: Seth Parker Date: Thu, 9 Jul 2026 15:19:05 -0400 Subject: [PATCH 2/3] Keep an empty texture slot for materials without a map_Kd MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A material that declares no map_Kd arrives as an empty relative path. Resolving it against the OBJ's parent directory would be wrong, so keep the chart's texture/path slots empty instead, preserving the chart i ↔ textures[i] alignment invariant. The reorder step already no-ops on empty charts. Co-Authored-By: Claude Opus 4.8 (1M context) --- core/src/MeshIO.cpp | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/core/src/MeshIO.cpp b/core/src/MeshIO.cpp index eab33dc..702d9ca 100644 --- a/core/src/MeshIO.cpp +++ b/core/src/MeshIO.cpp @@ -105,11 +105,20 @@ auto rt::ReadMesh(const fs::path& path) -> rt::MeshReadResult result.uvMap = FlipV(result.uvMap); // Resolve and load every referenced texture, keeping the vectors aligned - // with the UV map's chart indices (chart i ↔ textures[i]). A missing image - // is stored as an empty cv::Mat / empty path so alignment is preserved. + // with the UV map's chart indices (chart i ↔ textures[i]). A chart whose + // material declares no map_Kd (empty path) or whose image is missing is + // stored as an empty cv::Mat / empty path so alignment is preserved; the + // reorder step no-ops on empty charts. result.textures.reserve(texturePaths.size()); result.texturePaths.reserve(texturePaths.size()); for (const auto& rel : texturePaths) { + // A material without a map_Kd comes through as an empty path. Keep the + // slot empty rather than resolving it against the OBJ's parent dir. + if (rel.empty()) { + result.texturePaths.emplace_back(); + result.textures.emplace_back(); + continue; + } fs::path texPath = path.parent_path() / rel.string(); if (fs::exists(texPath)) { result.texturePaths.push_back(texPath); From 6ef59d099257178f0cfc7b41f4c0d2f4ec107d4b Mon Sep 17 00:00:00 2001 From: Seth Parker Date: Thu, 9 Jul 2026 15:19:14 -0400 Subject: [PATCH 3/3] Normalize MeshReadNode texture output on the plural images port Drop the singular imagePath output port, which had no consumers, and make the plural images port the canonical texture output. The scalar image port is kept as a documented stopgap convenience for single-image consumers (e.g. registration) and is now backed by a getter lambda over imgs_ rather than a duplicated img_ member, so it always reflects images[0]. The full plural-pipeline conversion of downstream consumers is deferred to a future PR. Co-Authored-By: Claude Opus 4.8 (1M context) --- graph/include/rt/graph/MeshIO.hpp | 18 ++++++++++-------- graph/src/MeshIO.cpp | 6 ------ 2 files changed, 10 insertions(+), 14 deletions(-) diff --git a/graph/include/rt/graph/MeshIO.hpp b/graph/include/rt/graph/MeshIO.hpp index c8f8f8e..cff78ef 100644 --- a/graph/include/rt/graph/MeshIO.hpp +++ b/graph/include/rt/graph/MeshIO.hpp @@ -35,10 +35,16 @@ class MeshReadNode : public smgl::Node /**@{*/ /** @brief Loaded mesh port */ smgl::OutputPort mesh{&mesh_}; - /** @brief First loaded image port (convenience; equals images[0]) */ - smgl::OutputPort image{&img_}; - /** @brief Loaded image path port */ - smgl::OutputPort imagePath{&imgPath_}; + /** + * @brief First loaded image port (convenience; equals images[0]) + * + * Stopgap scalar view of @ref images for single-texture consumers (e.g. + * registration). The canonical texture output is the plural @ref images; + * this port will go away once downstream consumers accept the image vector + * directly. + */ + smgl::OutputPort image{ + [this] { return imgs_.empty() ? cv::Mat() : imgs_.front(); }}; /** @brief All loaded texture images, indexed by UV chart */ smgl::OutputPort> images{&imgs_}; /** @brief Load UV Map port */ @@ -50,10 +56,6 @@ class MeshReadNode : public smgl::Node std::filesystem::path path_; /** Loaded mesh */ Mesh::Pointer mesh_; - /** First loaded image */ - cv::Mat img_; - /** Loaded image path */ - std::filesystem::path imgPath_; /** All loaded texture images, indexed by UV chart */ std::vector imgs_; /** Loaded UV map */ diff --git a/graph/src/MeshIO.cpp b/graph/src/MeshIO.cpp index 8c6ad2f..41f81b8 100644 --- a/graph/src/MeshIO.cpp +++ b/graph/src/MeshIO.cpp @@ -16,7 +16,6 @@ rtg::MeshReadNode::MeshReadNode() registerInputPort("path", path); registerOutputPort("mesh", mesh); registerOutputPort("image", image); - registerOutputPort("imagePath", imagePath); registerOutputPort("images", images); registerOutputPort("uvMap", uvMap); compute = [this]() { @@ -25,11 +24,6 @@ rtg::MeshReadNode::MeshReadNode() mesh_ = result.mesh; imgs_ = result.textures; uv_ = result.uvMap; - // The single image/imagePath ports expose the first texture (chart 0) - // for downstream single-image consumers (e.g. registration). - img_ = imgs_.empty() ? cv::Mat() : imgs_.front(); - imgPath_ = result.texturePaths.empty() ? std::filesystem::path() - : result.texturePaths.front(); }; }