diff --git a/CMakeLists.txt b/CMakeLists.txt index 7ae18f04aa..b06c38da66 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -85,6 +85,8 @@ include(Dependencies) include(CompilerCache) use_compiler_cache() +find_package(trx-cpp CONFIG REQUIRED) + if(EXISTS ${CMAKE_CURRENT_SOURCE_DIR}/.git AND NOT EXISTS ${CMAKE_CURRENT_SOURCE_DIR}/.git/hooks/pre-commit) message(WARNING "pre-commit git hook for clang-format not found!\n" diff --git a/cmake/Dependencies.cmake b/cmake/Dependencies.cmake index ba40cee6bc..c76f063813 100644 --- a/cmake/Dependencies.cmake +++ b/cmake/Dependencies.cmake @@ -80,6 +80,20 @@ add_library(nifti::nifti ALIAS nifti) target_include_directories(nifti INTERFACE "${NIFTI_INCLUDE_DIRS}") +# TRX Format +if(MRTRIX_USE_SYSTEM_TRXCPP) + find_package(trx-cpp CONFIG REQUIRED) +else() + set(trxcpp_url "https://github.com/tee-ar-ex/trx-cpp") + FetchContent_Declare( + trx-cpp + DOWNLOAD_EXTRACT_TIMESTAMP ON + GIT_REPOSITORY ${trxcpp_url} + GIT_TAG b628275c51f37f9c9070aff29d906e29453be3ce + ) + FetchContent_MakeAvailable(trx-cpp) +endif() + # Google Test if(MRTRIX_BUILD_TESTS) set(googletest_version 1.17.0) diff --git a/cpp/cmd/CMakeLists.txt b/cpp/cmd/CMakeLists.txt index 4de37c0462..e98cc1677c 100644 --- a/cpp/cmd/CMakeLists.txt +++ b/cpp/cmd/CMakeLists.txt @@ -36,6 +36,9 @@ function(add_cmd CMD_SRC IS_GUI) $,mrtrix::gui,mrtrix::core> mrtrix::executable-version ) + if(CMD_NAME STREQUAL "tckconvert") + target_link_libraries(${CMD_NAME} PRIVATE trx-cpp::trx) + endif() set_target_properties(${CMD_NAME} PROPERTIES LINK_DEPENDS_NO_SHARED true RUNTIME_OUTPUT_DIRECTORY ${PROJECT_BINARY_DIR}/bin diff --git a/cpp/cmd/tckconvert.cpp b/cpp/cmd/tckconvert.cpp index 11a41de462..e9601145bb 100644 --- a/cpp/cmd/tckconvert.cpp +++ b/cpp/cmd/tckconvert.cpp @@ -16,7 +16,8 @@ #include #include -#include +#include +#include #include "command.h" #include "dwi/tractography/file.h" @@ -24,7 +25,9 @@ #include "file/matrix.h" #include "file/name_parser.h" #include "file/ofstream.h" +#include "file/path.h" #include "raw.h" +#include using namespace MR; using namespace App; @@ -49,9 +52,19 @@ void usage() { DESCRIPTION + "The program currently supports" " MRtrix .tck files (input/output)," + " TRX .trx files (input/output)," " ascii text files (input/output)," " VTK polydata files (input/output)," - " and RenderMan RIB (export only)."; + " and RenderMan RIB (export only)." + + + "TRX files can include data per streamline (dps), data per vertex (dpv)," + " data per group (dpg), and group membership. By default, tckconvert only" + " modifies streamline geometry: these fields are preserved only for TRX->TRX" + " conversion and are discarded when converting to other formats that do not" + " support them. The -dps and -dpv options can be used to add new TRX data" + " entries during conversion. When applying a coordinate transform, only" + " the streamline positions are modified; any TRX metadata values are left" + " unchanged."; EXAMPLES + Example("Writing multiple ASCII files, one per streamline", @@ -62,8 +75,8 @@ void usage() { " output-0000.txt, output-0001.txt, output-0002.txt, ..."); ARGUMENTS - + Argument ("input", "the input track file.").type_tracks_in().type_file_in().type_text() - + Argument ("output", "the output track file.").type_tracks_out().type_file_out(); + + Argument ("input", "the input track file.").type_file_in().type_directory_in() + + Argument ("output", "the output track file.").type_file_out().type_directory_out(); OPTIONS + Option ("scanner2voxel", @@ -110,7 +123,23 @@ void usage() { + OptionGroup ("Options specific to VTK writer") + Option ("ascii", "write an ASCII VTK file" - " (binary by default)"); + " (binary by default)") + + + OptionGroup ("Options specific to TRX writer") + + + Option ("trxdirectory", "write an uncompressed TRX directory instead of a .trx file") + + + Option ("dps", "add data-per-streamline values from a text file") + .allow_multiple() + + Argument ("name").type_text() + + Argument ("datatype").type_text() + + Argument ("path").type_file_in() + + + Option ("dpv", "add data-per-vertex values from a TSF file") + .allow_multiple() + + Argument ("name").type_text() + + Argument ("datatype").type_text() + + Argument ("path").type_file_in(); } // clang-format on @@ -329,6 +358,204 @@ class ASCIIWriter : public WriterInterface { std::vector count; }; +struct DpsSpec { + std::string name; + std::string dtype; + std::string path; +}; + +struct DpvSpec { + std::string name; + std::string dtype; + std::string path; +}; + +static std::vector get_dps_specs() { + std::vector specs; + for (const auto &opt : get_options("dps")) { + if (opt.args.size() != 3) { + throw Exception("Option -dps requires 3 arguments: name datatype path"); + } + specs.push_back({std::string(opt[0]), std::string(opt[1]), std::string(opt[2])}); + } + return specs; +} + +static std::vector get_dpv_specs() { + std::vector specs; + for (const auto &opt : get_options("dpv")) { + if (opt.args.size() != 3) { + throw Exception("Option -dpv requires 3 arguments: name datatype path"); + } + specs.push_back({std::string(opt[0]), std::string(opt[1]), std::string(opt[2])}); + } + return specs; +} + +static bool trx_has_aux_data(const trx::TrxFile *trx) { + if (!trx) + return false; + return !(trx->groups.empty() && trx->data_per_streamline.empty() && trx->data_per_vertex.empty() && + trx->data_per_group.empty()); +} + +static bool is_trx_directory(std::string_view path) { + try { + return trx::is_trx_directory(std::string(path)); + } catch (const std::exception &) { + return false; + } +} + +static void apply_transform_to_trx(trx::TrxFile *trx, const transform_type &T) { + if (!trx || !trx->streamlines) + return; + if (T.isApprox(transform_type::Identity())) + return; + const auto T_float = T.cast(); + auto &data = trx->streamlines->_data; + for (Eigen::Index i = 0; i < data.rows(); ++i) { + Eigen::Vector3f pos(data(i, 0), data(i, 1), data(i, 2)); + pos = T_float * pos; + data(i, 0) = pos[0]; + data(i, 1) = pos[1]; + data(i, 2) = pos[2]; + } +} + +class TRXReader : public ReaderInterface { +public: + explicit TRXReader(std::string_view file) : trx(nullptr), current(0), num_streamlines(0), has_aux_data(false) { + try { + const std::string filename(file); + if (is_trx_directory(filename)) { + trx = trx::load_from_directory(filename); + } else { + trx = trx::load_from_zip(filename); + } + if (trx && trx->streamlines && trx->streamlines->_offsets.size() > 0) + num_streamlines = trx->streamlines->_offsets.size() - 1; + has_aux_data = trx_has_aux_data(trx.get()); + } catch (const std::exception &e) { + throw Exception(e.what()); + } + } + + bool operator()(Streamline &tck) { + tck.clear(); + if (!trx || current >= num_streamlines) + return false; + const Eigen::Index start = trx->streamlines->_offsets(current, 0); + const Eigen::Index end = trx->streamlines->_offsets(current + 1, 0); + tck.reserve(end - start); + for (Eigen::Index i = start; i < end; ++i) { + Eigen::Vector3f p; + p[0] = trx->streamlines->_data(i, 0); + p[1] = trx->streamlines->_data(i, 1); + p[2] = trx->streamlines->_data(i, 2); + tck.push_back(p); + } + ++current; + return true; + } + + ~TRXReader() { + if (!trx) + return; + try { + trx->close(); + trx.reset(); + } catch (const std::exception &e) { + Exception(e.what()).display(); + App::exit_error_code = 1; + } + } + + bool has_metadata() const { return has_aux_data; } + +private: + std::unique_ptr> trx; + Eigen::Index current; + Eigen::Index num_streamlines; + bool has_aux_data; +}; + +class TRXWriter : public WriterInterface { +public: + TRXWriter(std::string_view file, + size_t nb_streamlines, + size_t nb_vertices, + const std::vector &dps_specs, + const std::vector &dpv_specs, + std::string_view final_output, + bool rename_on_save) + : output(file), + final_output(final_output), + rename_on_save(rename_on_save), + current_streamline(0), + current_vertex(0) { + try { + trx.reset(new trx::TrxFile(static_cast(nb_vertices), static_cast(nb_streamlines), nullptr)); + if (trx->streamlines && trx->streamlines->_offsets.size() > 0) + trx->streamlines->_offsets(0, 0) = 0; + for (const auto &spec : dps_specs) { + trx->add_dps_from_text(spec.name, spec.dtype, spec.path); + } + for (const auto &spec : dpv_specs) { + trx->add_dpv_from_tsf(spec.name, spec.dtype, spec.path); + } + } catch (const std::exception &e) { + throw Exception(e.what()); + } + } + + bool operator()(const Streamline &tck) { + if (!trx || !trx->streamlines) + return false; + const Eigen::Index tck_size = static_cast(tck.size()); + for (Eigen::Index i = 0; i < tck_size; ++i) { + const auto &pos = tck[static_cast(i)]; + trx->streamlines->_data(current_vertex + i, 0) = pos[0]; + trx->streamlines->_data(current_vertex + i, 1) = pos[1]; + trx->streamlines->_data(current_vertex + i, 2) = pos[2]; + } + trx->streamlines->_lengths(current_streamline, 0) = static_cast(tck_size); + trx->streamlines->_offsets(current_streamline + 1, 0) = static_cast(current_vertex + tck_size); + current_vertex += tck_size; + ++current_streamline; + return true; + } + + ~TRXWriter() { + if (!trx) + return; + try { + trx->save(output, ZIP_CM_STORE); + if (rename_on_save) { + std::error_code ec; + std::filesystem::remove_all(final_output, ec); + std::filesystem::rename(output, final_output, ec); + if (ec) { + throw std::runtime_error("Failed to rename TRX directory to " + final_output + ": " + ec.message()); + } + } + trx->close(); + trx.reset(); + } catch (const std::exception &e) { + Exception(e.what()).display(); + App::exit_error_code = 1; + } + } + +private: + std::string output; + std::string final_output; + bool rename_on_save; + std::unique_ptr> trx; + Eigen::Index current_streamline; + Eigen::Index current_vertex; +}; + class PLYWriter : public WriterInterface { public: PLYWriter(std::string_view file, @@ -680,15 +907,116 @@ class RibWriter : public WriterInterface { }; void run() { + const bool input_is_trx_dir = is_trx_directory(argument[0]); + const bool input_is_trx = Path::has_suffix(argument[0], ".trx") || input_is_trx_dir; + const bool write_trx_directory = get_options("trxdirectory").size(); + const bool output_is_trx = Path::has_suffix(argument[1], ".trx") || write_trx_directory; + std::string trx_output = std::string(argument[1]); + std::string trx_save_path = trx_output; + bool rename_trx_directory = false; + if (write_trx_directory) { + if (!Path::has_suffix(trx_output, ".trx")) { + trx_output += ".trx"; + } + if (Path::has_suffix(trx_output, ".trx")) { + trx_save_path = trx_output.substr(0, trx_output.size() - 4); + rename_trx_directory = true; + } + } + const auto dps_specs = get_dps_specs(); + const auto dpv_specs = get_dpv_specs(); + if (!dps_specs.empty() && !output_is_trx) { + throw Exception("Option -dps requires TRX output."); + } + if (!dpv_specs.empty() && !output_is_trx) { + throw Exception("Option -dpv requires TRX output."); + } + + // Tranform matrix + transform_type T; + T.setIdentity(); + size_t nopts = 0; + auto opt = get_options("scanner2voxel"); + if (!opt.empty()) { + auto header = Header::open(opt[0][0]); + T = MR::Transform(header).scanner2voxel; + nopts++; + } + opt = get_options("scanner2image"); + if (!opt.empty()) { + auto header = Header::open(opt[0][0]); + T = MR::Transform(header).scanner2image; + nopts++; + } + opt = get_options("voxel2scanner"); + if (!opt.empty()) { + auto header = Header::open(opt[0][0]); + T = MR::Transform(header).voxel2scanner; + nopts++; + } + opt = get_options("image2scanner"); + if (!opt.empty()) { + auto header = Header::open(opt[0][0]); + T = MR::Transform(header).image2scanner; + nopts++; + } + if (nopts > 1) { + throw Exception("Transform options are mutually exclusive."); + } + + if (input_is_trx && output_is_trx) { + std::unique_ptr> trx; + try { + if (input_is_trx_dir) { + trx = trx::load_from_directory(argument[0]); + } else { + trx = trx::load_from_zip(argument[0]); + } + } catch (const std::exception &e) { + throw Exception(e.what()); + } + if (!trx) + throw Exception("Failed to load TRX input."); + apply_transform_to_trx(trx.get(), T); + for (const auto &spec : dps_specs) { + trx->add_dps_from_text(spec.name, spec.dtype, spec.path); + } + for (const auto &spec : dpv_specs) { + trx->add_dpv_from_tsf(spec.name, spec.dtype, spec.path); + } + try { + trx->save(trx_save_path, ZIP_CM_STORE); + if (rename_trx_directory) { + std::error_code ec; + std::filesystem::remove_all(trx_output, ec); + std::filesystem::rename(trx_save_path, trx_output, ec); + if (ec) { + throw Exception("Failed to rename TRX directory to " + trx_output + ": " + ec.message()); + } + } + trx->close(); + } catch (const std::exception &e) { + if (trx) + trx->close(); + throw Exception(e.what()); + } + return; + } + // Reader Properties properties; std::unique_ptr> reader; + TRXReader *trx_reader = nullptr; if (Path::has_suffix(argument[0], ".tck")) { reader.reset(new Reader(argument[0], properties)); } else if (Path::has_suffix(argument[0], ".txt")) { reader.reset(new ASCIIReader(argument[0])); } else if (Path::has_suffix(argument[0], ".vtk")) { reader.reset(new VTKReader(argument[0])); + } else if (input_is_trx) { + auto *trx_reader_ptr = new TRXReader(argument[0]); + reader.reset(trx_reader_ptr); + trx_reader = trx_reader_ptr; } else { throw Exception("Unsupported input file type."); } @@ -709,40 +1037,34 @@ void run() { writer.reset(new RibWriter(argument[1])); } else if (Path::has_suffix(argument[1], ".txt")) { writer.reset(new ASCIIWriter(argument[1])); + } else if (output_is_trx) { + size_t nb_streamlines = 0; + size_t nb_vertices = 0; + Streamline count_tck; + std::unique_ptr> count_reader; + if (Path::has_suffix(argument[0], ".tck")) { + count_reader.reset(new Reader(argument[0], properties)); + } else if (Path::has_suffix(argument[0], ".txt")) { + count_reader.reset(new ASCIIReader(argument[0])); + } else if (Path::has_suffix(argument[0], ".vtk")) { + count_reader.reset(new VTKReader(argument[0])); + } else if (input_is_trx) { + count_reader.reset(new TRXReader(argument[0])); + } else { + throw Exception("Unsupported input file type."); + } + while ((*count_reader)(count_tck)) { + nb_streamlines++; + nb_vertices += count_tck.size(); + } + writer.reset(new TRXWriter( + trx_save_path, nb_streamlines, nb_vertices, dps_specs, dpv_specs, trx_output, rename_trx_directory)); } else { throw Exception("Unsupported output file type."); } - // Tranform matrix - transform_type T; - T.setIdentity(); - size_t nopts = 0; - auto opt = get_options("scanner2voxel"); - if (!opt.empty()) { - auto header = Header::open(opt[0][0]); - T = Transform(header).scanner2voxel; - nopts++; - } - opt = get_options("scanner2image"); - if (!opt.empty()) { - auto header = Header::open(opt[0][0]); - T = Transform(header).scanner2image; - nopts++; - } - opt = get_options("voxel2scanner"); - if (!opt.empty()) { - auto header = Header::open(opt[0][0]); - T = Transform(header).voxel2scanner; - nopts++; - } - opt = get_options("image2scanner"); - if (!opt.empty()) { - auto header = Header::open(opt[0][0]); - T = Transform(header).image2scanner; - nopts++; - } - if (nopts > 1) { - throw Exception("Transform options are mutually exclusive."); + if (input_is_trx && !output_is_trx && trx_reader && trx_reader->has_metadata()) { + WARN("TRX metadata (dps/dpv/dpg/groups) will be discarded when converting to non-TRX formats."); } // Copy diff --git a/docs/reference/commands/tckconvert.rst b/docs/reference/commands/tckconvert.rst index 99657b0e14..c96e972b9f 100644 --- a/docs/reference/commands/tckconvert.rst +++ b/docs/reference/commands/tckconvert.rst @@ -21,7 +21,9 @@ Usage Description ----------- -The program currently supports MRtrix .tck files (input/output), ascii text files (input/output), VTK polydata files (input/output), and RenderMan RIB (export only). +The program currently supports MRtrix .tck files (input/output), TRX .trx files (input/output), ascii text files (input/output), VTK polydata files (input/output), and RenderMan RIB (export only). + +TRX files can include data per streamline (dps), data per vertex (dpv), data per group (dpg), and group membership. tckconvert only operates on streamline geometry: these fields are preserved only for TRX->TRX conversion and are discarded when converting to other formats that do not support them. When applying a coordinate transform, only the streamline positions are modified; any TRX metadata values are left unchanged. Example usages -------------- @@ -65,6 +67,15 @@ Options specific to VTK writer - **-ascii** write an ASCII VTK file (binary by default) +Options specific to TRX writer +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +- **-trxdirectory** write an uncompressed TRX directory instead of a .trx file + +- **-dps name datatype path** add data-per-streamline values from a text file (datatype: float16/float32/float64) + +- **-dpv name datatype path** add data-per-vertex values from a TSF file (datatype: float16/float32/float64) + Standard options ^^^^^^^^^^^^^^^^ diff --git a/testing/binaries/tests/tckconvert/trx_read b/testing/binaries/tests/tckconvert/trx_read new file mode 100644 index 0000000000..4ef54ae290 --- /dev/null +++ b/testing/binaries/tests/tckconvert/trx_read @@ -0,0 +1,3 @@ +#!/bin/bash +# convert TRX -> tck: +tckconvert gs.trx tmp.tck -force && testing_diff_tck tmp.tck gs.tck \ No newline at end of file diff --git a/testing/binaries/tests/tckconvert/trx_uncompressed_read b/testing/binaries/tests/tckconvert/trx_uncompressed_read new file mode 100644 index 0000000000..2ae068ae7a --- /dev/null +++ b/testing/binaries/tests/tckconvert/trx_uncompressed_read @@ -0,0 +1,3 @@ +#!/bin/bash +# convert TRX (uncompressed directory) -> tck: +tckconvert gs_fldr.trx tmp.tck -force && testing_diff_tck tmp.tck gs.tck \ No newline at end of file diff --git a/testing/binaries/tests/tckconvert/trx_write_dps_float16 b/testing/binaries/tests/tckconvert/trx_write_dps_float16 new file mode 100644 index 0000000000..51e72274ac --- /dev/null +++ b/testing/binaries/tests/tckconvert/trx_write_dps_float16 @@ -0,0 +1,10 @@ +#!/bin/bash +# Test adding a data-per-streamline value to a TRX with float16 datatype +# Also tests writing an uncompressed TRX directory. +# There are 13 streamlines in gs.tck, so we need 13 values in dps.txt: +for n in {0..12} +do + echo "${n}.0" >> dps.txt +done + +tckconvert gs.tck tmp_dps_float16.trx -trxdirectory -dps dps float16 dps.txt -force && [[ -f tmp_dps_float16.trx/dps/dps.float16 ]] \ No newline at end of file diff --git a/testing/binaries/tests/tckconvert/trx_write_dps_float32 b/testing/binaries/tests/tckconvert/trx_write_dps_float32 new file mode 100644 index 0000000000..ed5dc170b5 --- /dev/null +++ b/testing/binaries/tests/tckconvert/trx_write_dps_float32 @@ -0,0 +1,11 @@ +#!/bin/bash +# Test adding a data-per-streamline value to a TRX with float32 datatype +# Also tests writing an uncompressed TRX directory. +# There are 13 streamlines in gs.tck, so we need 13 values in dps.txt: +for n in {0..12} +do + echo "${n}.0" >> dps.txt +done + +# Test adding a data-per-streamline value to a TRX: +tckconvert gs.tck tmp_dps_float32.trx -trxdirectory -dps dps float32 dps.txt -force && [[ -f tmp_dps_float32.trx/dps/dps.float32 ]] \ No newline at end of file diff --git a/testing/binaries/tests/tckconvert/trx_write_dps_float64 b/testing/binaries/tests/tckconvert/trx_write_dps_float64 new file mode 100644 index 0000000000..7323b49eae --- /dev/null +++ b/testing/binaries/tests/tckconvert/trx_write_dps_float64 @@ -0,0 +1,11 @@ +#!/bin/bash +# Test adding a data-per-streamline value to a TRX with float64 datatype +# Also tests writing an uncompressed TRX directory. +# There are 13 streamlines in gs.tck, so we need 13 values in dps.txt: +for n in {0..12} +do + echo "${n}.0" >> dps.txt +done + +# Test adding a data-per-streamline value to a TRX: +tckconvert gs.tck tmp_dps_float64.trx -trxdirectory -dps dps float64 dps.txt -force && [[ -f tmp_dps_float64.trx/dps/dps.float64 ]] \ No newline at end of file