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..702d9ca 100644 --- a/core/src/MeshIO.cpp +++ b/core/src/MeshIO.cpp @@ -104,15 +104,30 @@ 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 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.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..cff78ef 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,18 @@ class MeshReadNode : public smgl::Node /**@{*/ /** @brief Loaded mesh port */ smgl::OutputPort mesh{&mesh_}; - /** @brief Loaded image port */ - 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 */ smgl::OutputPort uvMap{&uv_}; /**@}*/ @@ -47,10 +56,8 @@ class MeshReadNode : public smgl::Node std::filesystem::path path_; /** Loaded mesh */ Mesh::Pointer mesh_; - /** 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..41f81b8 100644 --- a/graph/src/MeshIO.cpp +++ b/graph/src/MeshIO.cpp @@ -16,14 +16,13 @@ rtg::MeshReadNode::MeshReadNode() registerInputPort("path", path); 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; }; } 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)); +}