diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..43cc93c --- /dev/null +++ b/.gitignore @@ -0,0 +1,33 @@ +# Build directories +build/ +_build/ +cmake-build-*/ + +# IDE files +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# CMake generated files +CMakeCache.txt +CMakeFiles/ +cmake_install.cmake +Makefile +*.cmake + +# Compiled binaries +*.exe +*.out +*.app +*.dylib +*.so +*.dll + +# Object files +*.o +*.obj + +# macOS +.DS_Store diff --git a/CMakeLists.txt b/CMakeLists.txt index b21a7d3..9605d17 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,6 +1,11 @@ cmake_minimum_required(VERSION 3.18) project(meshsdfilter-builder LANGUAGES CXX) +# Set C++17 standard for filesystem support +set(CMAKE_CXX_STANDARD 17) +set(CMAKE_CXX_STANDARD_REQUIRED OFF) +set(CMAKE_CXX_EXTENSIONS OFF) + include(FetchContent) list(APPEND CMAKE_MODULE_PATH "${CMAKE_CURRENT_SOURCE_DIR}/cmake") diff --git a/README.md b/README.md index 2c05291..76190ca 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ MeshSDFilter requires: OpenMP is an open standard for shared-memory parallelism; compilers that support it (e.g. GCC, Clang with `libomp`, MSVC) let MeshSDFilter run heavy loops across multiple CPU cores. -> Note: MeshSDFilter expects Eigen to be discoverable via `find_package(Eigen3)`. Our CI installs Eigen per-platform so you don’t have to. +> Note: MeshSDFilter expects Eigen to be discoverable via `find_package(Eigen3)`. Our CI installs Eigen per-platform so you don't have to. > You can also point CMake at a local Eigen install with `-DEIGEN3_INCLUDE_DIR=/path/to/eigen` if needed. ## Build (local) @@ -87,13 +87,74 @@ xattr -cr meshdenoiser-macos/ - This wrapper repo is MIT by default (you can change it), and preserves upstream notices. ## Using the tools + +### Single File Processing ```bash # Filter MeshSDFilter FilteringOptions.txt input_mesh.ply output_mesh.ply # Denoise MeshDenoiser DenoisingOptions.txt input_mesh.ply output_mesh.ply ``` -- A detail-preserving MeshDenoiser preset is in `MeshDenoiserDefaults.txt` (outer iterations 1, lambda 0.15, eta 2.2, mu 0.2, nu 0.25). Copy it to your working folder or pass it directly; raise lambda/eta or the iteration count only if you want stronger smoothing. + +### Batch Processing +Process multiple mesh files at once by providing directories instead of individual files: + +```bash +# Process all mesh files in input_dir/ and save to output_dir/ +MeshDenoiser DenoisingOptions.txt input_dir/ output_dir/ +``` + +Batch processing features: +- **Automatically discovers** all supported mesh files in the input directory +- **Preserves filenames** - each output file has the same name as its input +- **Creates output directory** if it doesn't exist +- **Progress indicators** - animated spinner showing loading, denoising, and saving progress +- **Real-time feedback** - displays mesh statistics (vertices, faces) and elapsed time +- **Error handling** - continues processing remaining files if one fails +- **Summary report** - displays success/failure counts and average processing time + +Example: +```bash +# Process a directory of scanned meshes +MeshDenoiser MeshDenoiserDefaults.txt scanned_meshes/ cleaned_meshes/ +``` + +Output example: +``` +Batch processing mode +Input directory: scanned_meshes/ +Output directory: cleaned_meshes/ + +Found 3 mesh file(s) to process + +[1/3] scan001.obj + Loaded mesh (12543 vertices, 25086 faces) (0.12s) + Complete (2.45s) + +[2/3] scan002.ply + Loaded mesh (8421 vertices, 16842 faces) (0.08s) + Complete (1.89s) + +[3/3] model.gltf + Loaded mesh (15632 vertices, 31264 faces) (0.15s) + Complete (3.12s) + +================================== +Batch processing complete + Successful: 3 + Failed: 0 + Total: 3 + Time: 7.56s (avg: 2.52s per file) +``` + +The progress indicator shows: +- **Loading phase**: Spinner animation with vertex/face count on completion +- **Denoising phase**: Spinner animation with elapsed time +- **Saving phase**: Quick spinner during file write +- Terminal animation works in interactive shells; falls back to simple dots in pipes/logs + +### Supported Formats +- A detail-preserving MeshDenoiser preset is in `MeshDenoiserDefaults.txt` (outer iterations 1, lambda 0.15, eta 2.2, mu 0.2, nu 0.25). Copy it to your working folder or pass it directly; raise lambda/eta or the iteration count only if you want stronger smoothing. - `MeshDenoiser` accepts: - Traditional formats: OBJ, PLY, OFF, STL (via OpenMesh) - glTF formats: `.gltf`, `.glb` (via tinygltf) diff --git a/overrides/MeshSDFilter/MeshDenoiser.cpp b/overrides/MeshSDFilter/MeshDenoiser.cpp index 4fa789e..01d21f7 100644 --- a/overrides/MeshSDFilter/MeshDenoiser.cpp +++ b/overrides/MeshSDFilter/MeshDenoiser.cpp @@ -45,6 +45,43 @@ #include #include #include +#include +#include +#include + +#if defined(_WIN32) || defined(_WIN64) + #include + #define ISATTY _isatty + #define FILENO _fileno +#else + #include + #define ISATTY isatty + #define FILENO fileno +#endif + +// Filesystem support +// Note: MSVC's std::experimental::filesystem has incomplete API, so we use native Windows API for MSVC +#if __cplusplus >= 201703L && !defined(_MSC_VER) + #include + namespace fs = std::filesystem; + #define HAS_FILESYSTEM +#elif defined(__has_include) && !defined(_MSC_VER) + #if __has_include() + #include + namespace fs = std::filesystem; + #define HAS_FILESYSTEM + #endif +#endif + +#ifndef HAS_FILESYSTEM + #include + #include + #if defined(_WIN32) || defined(_WIN64) + #include + #else + #include + #endif +#endif #define TINYGLTF_IMPLEMENTATION #define TINYGLTF_NO_STB_IMAGE @@ -56,6 +93,94 @@ #include "tydra/render-data.hh" #include "tydra/scene-access.hh" +// Simple progress indicator for terminal output +class ProgressIndicator +{ +public: + ProgressIndicator(const std::string &message, bool show_spinner = true) + : message_(message) + , show_spinner_(show_spinner && ISATTY(FILENO(stdout))) + , start_time_(std::chrono::steady_clock::now()) + , spinner_index_(0) + , active_(true) + { + if(show_spinner_){ + std::cout << message_ << " " << spinner_chars_[0] << std::flush; + } + else{ + std::cout << message_ << "..." << std::flush; + } + } + + ~ProgressIndicator() + { + finish(); + } + + void update() + { + if(!active_ || !show_spinner_){ + return; + } + + spinner_index_ = (spinner_index_ + 1) % spinner_chars_.size(); + auto now = std::chrono::steady_clock::now(); + auto elapsed = std::chrono::duration_cast(now - start_time_).count(); + + // Clear current line and redraw + std::cout << "\r" << message_ << " " << spinner_chars_[spinner_index_]; + if(elapsed > 0){ + std::cout << " (" << elapsed << "s)"; + } + std::cout << std::flush; + } + + void finish(const std::string &final_message = "") + { + if(!active_){ + return; + } + + active_ = false; + + auto now = std::chrono::steady_clock::now(); + auto elapsed = std::chrono::duration_cast(now - start_time_).count(); + + if(show_spinner_){ + std::cout << "\r"; + // Clear the line + std::cout << std::string(message_.length() + 20, ' ') << "\r"; + } + + if(!final_message.empty()){ + std::cout << final_message; + } + else{ + std::cout << message_ << " - done"; + } + + // Show elapsed time if significant + if(elapsed >= 100){ + std::cout << " (" << std::fixed << std::setprecision(2) << (elapsed / 1000.0) << "s)"; + } + + std::cout << std::endl; + } + + void set_message(const std::string &message) + { + message_ = message; + } + +private: + std::string message_; + bool show_spinner_; + std::chrono::steady_clock::time_point start_time_; + size_t spinner_index_; + bool active_; + const std::vector spinner_chars_ = {'|', '/', '-', '\\'}; +}; + namespace { @@ -548,66 +673,260 @@ bool load_usd_mesh(const std::string &filename, TriMesh &mesh, std::string &warn return true; } -} // anonymous namespace +// Directory and file utilities +bool is_directory(const std::string &path) +{ +#ifdef HAS_FILESYSTEM + return fs::is_directory(path); +#else + #if defined(_WIN32) || defined(_WIN64) + DWORD attrib = GetFileAttributesA(path.c_str()); + return (attrib != INVALID_FILE_ATTRIBUTES && (attrib & FILE_ATTRIBUTE_DIRECTORY)); + #else + struct stat st; + if(stat(path.c_str(), &st) != 0){ + return false; + } + return S_ISDIR(st.st_mode); + #endif +#endif +} +bool is_regular_file(const std::string &path) +{ +#ifdef HAS_FILESYSTEM + return fs::is_regular_file(path); +#else + #if defined(_WIN32) || defined(_WIN64) + DWORD attrib = GetFileAttributesA(path.c_str()); + if(attrib == INVALID_FILE_ATTRIBUTES){ + return false; + } + return !(attrib & FILE_ATTRIBUTE_DIRECTORY); + #else + struct stat st; + if(stat(path.c_str(), &st) != 0){ + return false; + } + return S_ISREG(st.st_mode); + #endif +#endif +} -int main(int argc, char **argv) +bool is_supported_mesh_extension(const std::string &path) { - if(argc != 4) - { - std::cout << "MeshDenoiser - Mesh normal denoising filter" << std::endl; - std::cout << std::endl; - std::cout << "Usage: MeshDenoiser OPTION_FILE INPUT_MESH OUTPUT_MESH" << std::endl; - std::cout << std::endl; - std::cout << "Supported input formats:" << std::endl; - std::cout << " - OBJ, PLY, OFF, STL (via OpenMesh)" << std::endl; - std::cout << " - glTF (.gltf, .glb)" << std::endl; - std::cout << " - USD (.usd, .usda, .usdc, .usdz)" << std::endl; - std::cout << std::endl; - std::cout << "Example:" << std::endl; - std::cout << " MeshDenoiser options.txt input.obj output.obj" << std::endl; - std::cout << " MeshDenoiser options.txt model.gltf denoised.obj" << std::endl; - std::cout << " MeshDenoiser options.txt scene.usdz clean.ply" << std::endl; - return 1; + // List of supported extensions + static const std::vector supported = { + ".obj", ".ply", ".off", ".stl", // OpenMesh formats + ".gltf", ".glb", // glTF formats + ".usd", ".usda", ".usdc", ".usdz" // USD formats + }; + + for(const auto &ext : supported){ + if(has_extension(path, ext)){ + return true; + } } + return false; +} - const std::string option_file = argv[1]; - const std::string input_mesh_path = argv[2]; - const std::string output_mesh_path = argv[3]; +std::vector list_mesh_files(const std::string &directory) +{ + std::vector files; + +#ifdef HAS_FILESYSTEM + try{ + for(const auto &entry : fs::directory_iterator(directory)){ + if(entry.is_regular_file()){ + std::string filename = entry.path().filename().string(); + if(is_supported_mesh_extension(filename)){ + files.push_back(filename); + } + } + } + } + catch(const fs::filesystem_error &e){ + std::cerr << "Error reading directory: " << e.what() << std::endl; + } +#else + #if defined(_WIN32) || defined(_WIN64) + WIN32_FIND_DATAA find_data; + std::string search_path = directory + "\\*"; + HANDLE handle = FindFirstFileA(search_path.c_str(), &find_data); + + if(handle != INVALID_HANDLE_VALUE){ + do{ + if(!(find_data.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY)){ + std::string filename = find_data.cFileName; + if(is_supported_mesh_extension(filename)){ + files.push_back(filename); + } + } + }while(FindNextFileA(handle, &find_data)); + FindClose(handle); + } + #else + DIR *dir = opendir(directory.c_str()); + if(dir){ + struct dirent *entry; + while((entry = readdir(dir)) != nullptr){ + if(entry->d_type == DT_REG || entry->d_type == DT_UNKNOWN){ + std::string filename = entry->d_name; + if(is_supported_mesh_extension(filename)){ + // Check if it's actually a regular file (for DT_UNKNOWN) + std::string full_path = directory + "/" + filename; + struct stat st; + if(stat(full_path.c_str(), &st) == 0 && S_ISREG(st.st_mode)){ + files.push_back(filename); + } + } + } + } + closedir(dir); + } + #endif +#endif + + std::sort(files.begin(), files.end()); + return files; +} + +std::string join_path(const std::string &dir, const std::string &filename) +{ +#ifdef HAS_FILESYSTEM + return (fs::path(dir) / filename).string(); +#else + #if defined(_WIN32) || defined(_WIN64) + return dir + "\\" + filename; + #else + return dir + "/" + filename; + #endif +#endif +} + +bool create_directory_if_not_exists(const std::string &path) +{ +#ifdef HAS_FILESYSTEM + try{ + if(!fs::exists(path)){ + return fs::create_directories(path); + } + return true; + } + catch(const fs::filesystem_error &e){ + std::cerr << "Error creating directory: " << e.what() << std::endl; + return false; + } +#else + #if defined(_WIN32) || defined(_WIN64) + return CreateDirectoryA(path.c_str(), NULL) != 0 || GetLastError() == ERROR_ALREADY_EXISTS; + #else + struct stat st; + if(stat(path.c_str(), &st) == 0){ + return S_ISDIR(st.st_mode); + } + return mkdir(path.c_str(), 0755) == 0; + #endif +#endif +} +// Process a single mesh file +bool process_single_mesh(const std::string &input_mesh_path, + const std::string &output_mesh_path, + const SDFilter::MeshDenoisingParameters ¶m, + bool show_progress = true, + const std::string &indent = "") +{ TriMesh mesh; bool load_success = false; std::string warning; std::string error; - if(has_extension(input_mesh_path, ".gltf") || has_extension(input_mesh_path, ".glb")){ - load_success = load_gltf_mesh(input_mesh_path, mesh, warning, error); - } - else if(has_extension(input_mesh_path, ".usd") || has_extension(input_mesh_path, ".usda") || - has_extension(input_mesh_path, ".usdc") || has_extension(input_mesh_path, ".usdz")){ - load_success = load_usd_mesh(input_mesh_path, mesh, warning, error); + // Load mesh based on file extension + if(show_progress){ + ProgressIndicator progress(indent + "Loading mesh"); + if(has_extension(input_mesh_path, ".gltf") || has_extension(input_mesh_path, ".glb")){ + load_success = load_gltf_mesh(input_mesh_path, mesh, warning, error); + } + else if(has_extension(input_mesh_path, ".usd") || has_extension(input_mesh_path, ".usda") || + has_extension(input_mesh_path, ".usdc") || has_extension(input_mesh_path, ".usdz")){ + load_success = load_usd_mesh(input_mesh_path, mesh, warning, error); + } + else{ + load_success = OpenMesh::IO::read_mesh(mesh, input_mesh_path); + if(!load_success){ + error = "unable to read input mesh with OpenMesh"; + } + } + if(load_success){ + std::ostringstream oss; + oss << indent << "Loaded mesh (" << mesh.n_vertices() << " vertices, " << mesh.n_faces() << " faces)"; + progress.finish(oss.str()); + } + else{ + progress.finish(indent + "Loading failed"); + } } else{ - load_success = OpenMesh::IO::read_mesh(mesh, input_mesh_path); - if(!load_success){ - error = "unable to read input mesh with OpenMesh"; + if(has_extension(input_mesh_path, ".gltf") || has_extension(input_mesh_path, ".glb")){ + load_success = load_gltf_mesh(input_mesh_path, mesh, warning, error); + } + else if(has_extension(input_mesh_path, ".usd") || has_extension(input_mesh_path, ".usda") || + has_extension(input_mesh_path, ".usdc") || has_extension(input_mesh_path, ".usdz")){ + load_success = load_usd_mesh(input_mesh_path, mesh, warning, error); + } + else{ + load_success = OpenMesh::IO::read_mesh(mesh, input_mesh_path); + if(!load_success){ + error = "unable to read input mesh with OpenMesh"; + } } } if(!warning.empty()){ - std::cout << "Warning while loading mesh: " << warning << std::endl; + std::cout << indent << "Warning: " << warning << std::endl; + } + + if(!load_success){ + std::cerr << indent << "Error: " << error << std::endl; + return false; } - if(!load_success) + // Normalize the input mesh + Eigen::Vector3d original_center; + double original_scale; + SDFilter::normalize_mesh(mesh, original_center, original_scale); + + // Filter the normals and construct the output mesh { - std::cerr << "Error: " << error << std::endl; - return 1; + ProgressIndicator progress(indent + "Denoising mesh", show_progress); + SDFilter::MeshNormalDenoising denoiser(mesh); + TriMesh output_mesh; + if(!denoiser.denoise(param, output_mesh)){ + progress.finish(indent + "Denoising failed"); + std::cerr << indent << "Error: denoising failed" << std::endl; + return false; + } + SDFilter::restore_mesh(output_mesh, original_center, original_scale); + + // Save output mesh + progress.set_message(indent + "Saving mesh"); + if(!SDFilter::write_mesh_high_accuracy(output_mesh, output_mesh_path)){ + progress.finish(indent + "Saving failed"); + std::cerr << indent << "Error: unable to save result mesh to " << output_mesh_path << std::endl; + return false; + } + progress.finish(indent + "Complete"); } -#ifdef USE_OPENMP - Eigen::initParallel(); -#endif + return true; +} +// Batch process all mesh files in a directory +int process_batch(const std::string &option_file, + const std::string &input_dir, + const std::string &output_dir) +{ // Load option file SDFilter::MeshDenoisingParameters param; if(!param.load(option_file.c_str())){ @@ -618,28 +937,151 @@ int main(int argc, char **argv) std::cerr << "Invalid filter options. Aborting..." << std::endl; return 1; } + + std::cout << "Batch processing mode" << std::endl; + std::cout << "Input directory: " << input_dir << std::endl; + std::cout << "Output directory: " << output_dir << std::endl; + std::cout << std::endl; param.output(); + std::cout << std::endl; + // Create output directory if it doesn't exist + if(!create_directory_if_not_exists(output_dir)){ + std::cerr << "Error: unable to create output directory " << output_dir << std::endl; + return 1; + } - // Normalize the input mesh - Eigen::Vector3d original_center; - double original_scale; - SDFilter::normalize_mesh(mesh, original_center, original_scale); + // List all mesh files in input directory + std::vector mesh_files = list_mesh_files(input_dir); - // Filter the normals and construct the output mesh - SDFilter::MeshNormalDenoising denoiser(mesh); - TriMesh output_mesh; - if(!denoiser.denoise(param, output_mesh)){ + if(mesh_files.empty()){ + std::cerr << "Error: no supported mesh files found in " << input_dir << std::endl; + return 1; + } + + std::cout << "Found " << mesh_files.size() << " mesh file(s) to process" << std::endl; + std::cout << std::endl; + + int success_count = 0; + int failure_count = 0; + auto batch_start = std::chrono::steady_clock::now(); + + // Process each file + for(size_t i = 0; i < mesh_files.size(); ++i){ + const std::string &filename = mesh_files[i]; + std::string input_path = join_path(input_dir, filename); + std::string output_path = join_path(output_dir, filename); + + std::cout << "[" << (i + 1) << "/" << mesh_files.size() << "] " << filename << std::endl; + + if(process_single_mesh(input_path, output_path, param, true, " ")){ + ++success_count; + } + else{ + ++failure_count; + } + std::cout << std::endl; + } + + // Summary with total elapsed time + auto batch_end = std::chrono::steady_clock::now(); + auto total_elapsed = std::chrono::duration_cast(batch_end - batch_start).count(); + + std::cout << "==================================" << std::endl; + std::cout << "Batch processing complete" << std::endl; + std::cout << " Successful: " << success_count << std::endl; + std::cout << " Failed: " << failure_count << std::endl; + std::cout << " Total: " << mesh_files.size() << std::endl; + std::cout << " Time: " << std::fixed << std::setprecision(2) << (total_elapsed / 1000.0) << "s"; + if(success_count > 0){ + std::cout << " (avg: " << std::fixed << std::setprecision(2) << (total_elapsed / 1000.0 / success_count) << "s per file)"; + } + std::cout << std::endl; + + return failure_count > 0 ? 1 : 0; +} + +} // anonymous namespace + + +int main(int argc, char **argv) +{ + if(argc != 4) + { + std::cout << "MeshDenoiser - Mesh normal denoising filter" << std::endl; + std::cout << std::endl; + std::cout << "Usage: MeshDenoiser OPTION_FILE INPUT OUTPUT" << std::endl; + std::cout << std::endl; + std::cout << "Single file mode:" << std::endl; + std::cout << " MeshDenoiser OPTION_FILE INPUT_MESH OUTPUT_MESH" << std::endl; + std::cout << std::endl; + std::cout << "Batch processing mode:" << std::endl; + std::cout << " MeshDenoiser OPTION_FILE INPUT_DIR/ OUTPUT_DIR/" << std::endl; + std::cout << " (processes all mesh files in INPUT_DIR)" << std::endl; + std::cout << std::endl; + std::cout << "Supported input formats:" << std::endl; + std::cout << " - OBJ, PLY, OFF, STL (via OpenMesh)" << std::endl; + std::cout << " - glTF (.gltf, .glb)" << std::endl; + std::cout << " - USD (.usd, .usda, .usdc, .usdz)" << std::endl; + std::cout << std::endl; + std::cout << "Examples:" << std::endl; + std::cout << " MeshDenoiser options.txt input.obj output.obj" << std::endl; + std::cout << " MeshDenoiser options.txt model.gltf denoised.obj" << std::endl; + std::cout << " MeshDenoiser options.txt scene.usdz clean.ply" << std::endl; + std::cout << " MeshDenoiser options.txt input_dir/ output_dir/" << std::endl; return 1; } - SDFilter::restore_mesh(output_mesh, original_center, original_scale); + const std::string option_file = argv[1]; + const std::string input_path = argv[2]; + const std::string output_path = argv[3]; + +#ifdef USE_OPENMP + Eigen::initParallel(); +#endif + + // Determine processing mode based on input type + bool input_is_dir = is_directory(input_path); + + if(input_is_dir){ + // Batch processing mode: input is a directory + // Check if output exists and is a regular file (error case) + if(is_regular_file(output_path)){ + std::cerr << "Error: Input is a directory but output is an existing file." << std::endl; + std::cerr << "For batch processing, output must be a directory path (will be created if needed)." << std::endl; + return 1; + } + // Output is either a directory or doesn't exist yet (will be created in process_batch) + return process_batch(option_file, input_path, output_path); + } + else{ + // Single file processing mode: input is a file + // Check if output is an existing directory (error case - need filename) + if(is_directory(output_path)){ + std::cerr << "Error: Input is a file but output is a directory." << std::endl; + std::cerr << "For single file mode, output must be a file path (e.g., output_dir/file.obj)." << std::endl; + return 1; + } + } + + // Single file processing mode + // Load option file + SDFilter::MeshDenoisingParameters param; + if(!param.load(option_file.c_str())){ + std::cerr << "Error: unable to load option file " << option_file << std::endl; + return 1; + } + if(!param.valid_parameters()){ + std::cerr << "Invalid filter options. Aborting..." << std::endl; + return 1; + } + param.output(); - // Save output mesh - if(!SDFilter::write_mesh_high_accuracy(output_mesh, output_mesh_path)){ - std::cerr << "Error: unable to save the result mesh to file " << output_mesh_path << std::endl; + // Process the single mesh file + if(!process_single_mesh(input_path, output_path, param)){ return 1; } + std::cout << "Denoising complete!" << std::endl; return 0; }