diff --git a/CMakeLists.txt b/CMakeLists.txt index 7ae18f04aa..c81fe5f9fd 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -35,6 +35,8 @@ option(MRTRIX_USE_SYSTEM_GTEST "Use system-installed Google Test library" OFF) option(MRTRIX_USE_SYSTEM_DAWN "Use system-installed Dawn library" OFF) option(MRTRIX_USE_SYSTEM_SLANG "Use system-installed Slang library" OFF) option(MRTRIX_USE_SYSTEM_TCB_SPAN "Use system-installed TCB Span library" OFF) +option(MRTRIX_USE_SYSTEM_TRXCPP "Use system-installed trx-cpp library" OFF) +set(MRTRIX_TRXCPP_SOURCE_DIR "" CACHE PATH "Path to a local trx-cpp source tree (overrides GitHub fetch)") if(MRTRIX_BUILD_TESTS) list(APPEND CMAKE_CTEST_ARGUMENTS "--output-on-failure") diff --git a/cmake/Dependencies.cmake b/cmake/Dependencies.cmake index ba40cee6bc..a2840d62f8 100644 --- a/cmake/Dependencies.cmake +++ b/cmake/Dependencies.cmake @@ -80,6 +80,35 @@ 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() + # Tell trx-cpp to use MRtrix3's Eigen target directly, skipping its own + # Eigen discovery. This works because Eigen3::Eigen is already defined + # above (either from system or FetchContent). + set(TRX_EIGEN3_TARGET Eigen3::Eigen) + set(TRX_BUILD_EXAMPLES OFF) + set(TRX_ENABLE_INSTALL OFF) + set(TRX_ENABLE_NIFTI OFF) + if(MRTRIX_TRXCPP_SOURCE_DIR) + FetchContent_Declare( + trx-cpp + DOWNLOAD_EXTRACT_TIMESTAMP ON + GIT_REPOSITORY file://${MRTRIX_TRXCPP_SOURCE_DIR} + GIT_TAG HEAD + ) + else() + FetchContent_Declare( + trx-cpp + DOWNLOAD_EXTRACT_TIMESTAMP ON + GIT_REPOSITORY https://github.com/tee-ar-ex/trx-cpp.git + GIT_TAG main + ) + endif() + 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/afdconnectivity.cpp b/cpp/cmd/afdconnectivity.cpp index 538187ff32..6c599fd4b6 100644 --- a/cpp/cmd/afdconnectivity.cpp +++ b/cpp/cmd/afdconnectivity.cpp @@ -22,6 +22,7 @@ #include "dwi/tractography/mapping/mapper.h" #include "dwi/tractography/mapping/mapping.h" #include "dwi/tractography/properties.h" +#include "dwi/tractography/trx_utils.h" #include "memory.h" #include "mrtrix_version.h" @@ -82,13 +83,13 @@ void usage() { ARGUMENTS + Argument ("image", "the input FOD image.").type_image_in() - + Argument ("tracks", "the input track file defining the bundle of interest.").type_tracks_in(); + + Argument ("tracks", "the input track file defining the bundle of interest.").type_tracks_in().type_directory_in(); OPTIONS + Option ("wbft", "provide a whole-brain fibre-tracking data set" " (of which the input track file should be a subset)" ", to improve the estimate of fibre bundle volume in the presence of partial volume") - + Argument ("tracks").type_tracks_in() + + Argument ("tracks").type_tracks_in().type_directory_in() + Option ("afd_map", "output a 3D image containing the AFD estimated for each voxel.") + Argument ("image").type_image_out() @@ -163,9 +164,9 @@ class AFDConnectivity : public DWI::Tractography::SIFT::ModelBase value_type AFDConnectivity::get(std::string_view path) { Tractography::Properties properties; - Tractography::Reader reader(path, properties); + auto reader = Tractography::TRX::open_tractogram(path, properties); const size_t track_count = (properties.find("count") == properties.end() ? 0 : to(properties["count"])); - DWI::Tractography::Mapping::TrackLoader loader(reader, track_count, "summing apparent fibre density within track"); + DWI::Tractography::Mapping::TrackLoader loader(*reader, track_count, "summing apparent fibre density within track"); // If WBFT is provided, this is the sum of (volume/length) across streamlines // Otherwise, it's a sum of lengths of all streamlines (for later scaling by mean streamline length) diff --git a/cpp/cmd/connectome2tck.cpp b/cpp/cmd/connectome2tck.cpp index 4d7f17f496..bdabc8c186 100644 --- a/cpp/cmd/connectome2tck.cpp +++ b/cpp/cmd/connectome2tck.cpp @@ -30,6 +30,7 @@ #include "dwi/tractography/file.h" #include "dwi/tractography/mapping/loader.h" #include "dwi/tractography/properties.h" +#include "dwi/tractography/trx_utils.h" #include "dwi/tractography/weights.h" using namespace MR; @@ -38,6 +39,7 @@ using namespace MR::Connectome; using namespace MR::DWI; using namespace MR::DWI::Tractography; using namespace MR::DWI::Tractography::Connectome; +using namespace MR::DWI::Tractography::TRX; const std::vector file_outputs = {"per_edge", "per_node", "single"}; @@ -90,7 +92,22 @@ void usage() { " (most typically there will be two entries per streamline," " one for each endpoint;" " but this is not strictly a requirement)." - " This file will most typically be generated using the tck2connectome command with the -out_assignments option."; + " This file will most typically be generated using the tck2connectome command with the -out_assignments option." + + // TRX mode: when the input tractogram is a TRX file that has been labeled by + // trxlabel, the external assignments file can be skipped entirely. Pass "-" + // as assignments_in to trigger group-based assignment derivation. Node indices + // are recovered from the group names: if names parse as integers (trxlabel + // without -lut), the integer is used directly, so -nodes 1,2 refers to the same + // atlas indices as in the tck2connectome workflow. For LUT-based names, groups + // are ordered alphabetically and assigned 1-based indices (matching + // trx2connectome's convention). + + "For TRX input: pass \"-\" as assignments_in to derive node assignments directly" + " from the groups embedded in the TRX file (as created by trxlabel)," + " eliminating the need for a separate assignments file." + " Alternatively, pass a pre-existing text assignments file as normal — both modes work with TRX input." + " Use -group_prefix to restrict extraction to groups from one specific atlas" + " when a TRX file contains groups from multiple atlases."; EXAMPLES + Example ("Default usage", @@ -149,69 +166,165 @@ void usage() { "connectome2tck tracks.tck assignments.txt exemplars.tck -files single -exemplars nodes.mif", "This produces the track file that is required as input" " when attempting to display connectome edges using the streamlines or streamtubes geometries" - " within the mrview connectome tool."); + " within the mrview connectome tool.") + + + Example ("TRX input: derive assignments from embedded groups (no assignments file needed)", + "trxlabel tracks.trx nodes.mif labeled.trx; connectome2tck labeled.trx - edge-", + "Pass \"-\" as the assignments argument when the input TRX has been labeled by trxlabel." + " Group names that are plain integers (default when trxlabel is run without -lut)" + " map directly to node indices, so -nodes 1,2 selects the same nodes as the TCK workflow.") + + + Example ("TRX input with multiple atlases: restrict to one atlas via -group_prefix", + "connectome2tck labeled.trx - edge- -group_prefix dk -nodes 1,2 -exclusive -files single", + "When a TRX file has groups from multiple atlases (e.g. dk_1, dk_2, aal_1, aal_2)," + " -group_prefix restricts extraction to a single atlas." + " The prefix is stripped when deriving node indices, so dk_1 → node 1."); ARGUMENTS - + Argument ("tracks_in", "the input track file").type_file_in() - + Argument ("assignments_in", "input text file containing the node assignments for each streamline").type_file_in() + + Argument ("tracks_in", "the input track file").type_tracks_in().type_directory_in() + + Argument ("assignments_in", "input text file containing the node assignments for each streamline" + " (as produced by tck2connectome -out_assignments);" + " for TRX input, pass \"-\" to derive assignments from embedded groups" + " (requires prior labeling with trxlabel)").type_text() + Argument ("prefix_out", "the output file / prefix").type_text(); OPTIONS + TrackOutputOptions - + TrackWeightsOptions; + + TrackWeightsOptions + + // TRX-specific option: filter to a single atlas when the TRX contains groups + // from multiple atlases (each labeled with a different -prefix in trxlabel). + + Option ("group_prefix", "when input is a TRX file with groups from multiple atlases," + " only include groups whose name begins with this prefix;" + " the prefix (and trailing underscore) is stripped when computing node indices." + " Has no effect when assignments_in is a text file.") + + Argument ("prefix").type_text(); } // clang-format on +// Derive per-streamline node assignments from TRX group membership. +// +// This is the inverse of the group-building pass performed by trxlabel, and +// produces the same assignments_lists structure that the text-file parser below +// produces from a tck2connectome -out_assignments file — so the rest of run() +// is identical regardless of which source was used. +// +// Node index assignment strategy: +// - If all group names (after stripping the prefix) parse as integers, the +// integer values are used directly as node_t indices. This is the common +// case when trxlabel is used without -lut, and means -nodes 1,2 refers to +// the same atlas parcels as in the tck2connectome / tck2nodes workflow. +// - Otherwise (LUT-based names) groups are sorted alphabetically and assigned +// 1-based indices, matching trx2connectome's ordering convention. +static std::vector> +assignments_from_trx_groups(const std::string &trx_path, const std::string &prefix_filter, node_t &max_node_index) { + auto trx = load_trx_header_only(trx_path); + if (!trx || !trx->streamlines) + throw Exception("Failed to load TRX file: " + trx_path); + if (trx->groups.empty()) + throw Exception("TRX file has no groups; run trxlabel to assign streamlines to nodes first"); + + const std::string prefix = prefix_filter.empty() ? "" : prefix_filter + "_"; + std::vector group_names = collect_group_names(*trx, prefix); + if (group_names.empty()) + throw Exception("No TRX groups match prefix '" + prefix_filter + "'"); + + GroupNodeMapping mapping = build_group_node_mapping(group_names, prefix); + if (!mapping.integer_names) { + INFO("TRX group names are non-integer; assigning 1-based indices in alphabetical order"); + } + max_node_index = std::max(max_node_index, mapping.max_node_index); + + const auto memberships_u32 = invert_group_memberships(*trx, mapping.group_to_node); + std::vector> assignments; + assignments.resize(memberships_u32.size()); + for (size_t i = 0; i < memberships_u32.size(); ++i) { + assignments[i].reserve(memberships_u32[i].size()); + for (const auto n : memberships_u32[i]) + assignments[i].push_back(static_cast(n)); + } + return assignments; +} + void run() { + // TRX mode: when input is TRX and assignments_in is "-" (or another TRX path), + // skip the external assignments file and derive node assignments from the groups + // embedded in the TRX by trxlabel. All downstream logic (pair optimisation, + // node filtering, exemplar generation, track extraction) is identical. + const bool trx_in = is_trx(std::string(argument[0])); + const std::string assignments_arg(argument[1]); + const bool derive_from_groups = trx_in && (assignments_arg == "-" || is_trx(assignments_arg)); + Tractography::Properties properties; - Tractography::Reader reader(argument[0], properties); + // open_tractogram handles both TCK and TRX transparently and populates + // properties["count"] in both cases, replacing Reader(path, properties). + auto reader = open_tractogram(argument[0], properties); std::vector> assignments_lists; assignments_lists.reserve(to(properties["count"])); std::vector assignments_pairs; bool nonpair_found = false; node_t max_node_index = 0; - { - std::ifstream stream(argument[1]); - std::string line; - ProgressBar progress("reading streamline assignments file"); - while (std::getline(stream, line)) { - line = strip(line.substr(0, line.find_first_of('#'))); - if (line.empty()) - continue; - std::stringstream line_stream(line); - std::vector nodes; - while (1) { - node_t n; - line_stream >> n; - if (!line_stream) - break; - nodes.push_back(n); - max_node_index = std::max(max_node_index, n); - } - if (nodes.size() != 2) + + if (derive_from_groups) { + // TRX group mode: invert the embedded group → streamline mapping. + // -group_prefix restricts to one atlas when the TRX contains groups from several. + std::string group_prefix; + auto opt = get_options("group_prefix"); + if (!opt.empty()) + group_prefix = std::string(opt[0][0]); + + assignments_lists = assignments_from_trx_groups(std::string(argument[0]), group_prefix, max_node_index); + + for (const auto &nodes : assignments_lists) + if (nodes.size() != 2) { nonpair_found = true; - assignments_lists.push_back(std::move(nodes)); - ++progress; + break; + } + } else { + // Text file mode: read the assignments file produced by tck2connectome -out_assignments. + if (!Path::exists(assignments_arg)) + throw Exception("Assignments file not found: " + assignments_arg + + (trx_in ? " (for TRX input without an assignments file, pass \"-\" as assignments_in)" : "")); + { + std::ifstream stream(assignments_arg); + std::string line; + ProgressBar progress("reading streamline assignments file"); + while (std::getline(stream, line)) { + line = strip(line.substr(0, line.find_first_of('#'))); + if (line.empty()) + continue; + std::stringstream line_stream(line); + std::vector nodes; + while (1) { + node_t n; + line_stream >> n; + if (!line_stream) + break; + nodes.push_back(n); + max_node_index = std::max(max_node_index, n); + } + if (nodes.size() != 2) + nonpair_found = true; + assignments_lists.push_back(std::move(nodes)); + ++progress; + } } } - INFO("Maximum node index in assignments file is " + str(max_node_index)); + + INFO("Maximum node index in assignments is " + str(max_node_index)); const size_t count = to(properties["count"]); if (assignments_lists.size() != count) - throw Exception("Assignments file contains " + str(assignments_lists.size()) + " entries; track file contains " + + throw Exception("Assignments contain " + str(assignments_lists.size()) + " entries; track file contains " + str(count) + " tracks"); - // If the node assignments have been performed in such a way that each streamline is - // assigned to precisely two nodes, use the assignments_pairs class which is - // designed as such. This _should_ be the majority of cases, but the situation - // where each streamline could potentially be assigned to any number of nodes is - // now supported. + // If every streamline maps to exactly two nodes, use the pair-optimised path if (!nonpair_found) { - INFO("Assignments file contains node pair for every streamline; operating accordingly"); + INFO("Assignments contain node pair for every streamline; operating accordingly"); assignments_pairs.reserve(assignments_lists.size()); for (auto i = assignments_lists.begin(); i != assignments_lists.end(); ++i) assignments_pairs.push_back(NodePair((*i)[0], (*i)[1])); @@ -281,8 +394,7 @@ void run() { if (COMs.size() > max_node_index + 1) { WARN("Parcellation image \"" + std::string(opt[0][0]) + "\" provided via -exemplars option contains more nodes (" + str(COMs.size() - 1) + - ") than are present in input assignments file \"" + std::string(argument[1]) + "\" (" + str(max_node_index) + - ")"); + ") than are present in input assignments (" + str(max_node_index) + ")"); max_node_index = COMs.size() - 1; } Transform transform(image); @@ -301,7 +413,7 @@ void run() { ProgressBar progress("generating exemplars for connectome", count); if (!assignments_pairs.empty()) { auto loader = [&](Tractography::Connectome::Streamline_nodepair &out) { - if (!reader(out)) + if (!(*reader)(out)) return false; out.set_nodes(assignments_pairs[out.get_index()]); return true; @@ -316,7 +428,7 @@ void run() { loader, Thread::batch(Tractography::Connectome::Streamline_nodepair()), Thread::multi(worker)); } else { auto loader = [&](Tractography::Connectome::Streamline_nodelist &out) { - if (!reader(out)) + if (!(*reader)(out)) return false; out.set_nodes(assignments_lists[out.get_index()]); return true; @@ -425,14 +537,14 @@ void run() { ProgressBar progress("Extracting tracks from connectome", count); if (assignments_pairs.empty()) { Tractography::Connectome::Streamline_nodelist tck; - while (reader(tck)) { + while ((*reader)(tck)) { tck.set_nodes(assignments_lists[tck.get_index()]); writer(tck); ++progress; } } else { Tractography::Connectome::Streamline_nodepair tck; - while (reader(tck)) { + while ((*reader)(tck)) { tck.set_nodes(assignments_pairs[tck.get_index()]); writer(tck); ++progress; diff --git a/cpp/cmd/fixel2tsf.cpp b/cpp/cmd/fixel2tsf.cpp index 9a66e6c5d9..1eeb158c8d 100644 --- a/cpp/cmd/fixel2tsf.cpp +++ b/cpp/cmd/fixel2tsf.cpp @@ -25,6 +25,7 @@ #include "dwi/tractography/file.h" #include "dwi/tractography/scalar_file.h" #include "dwi/tractography/streamline.h" +#include "dwi/tractography/trx_utils.h" #include "dwi/tractography/mapping/loader.h" #include "dwi/tractography/mapping/mapper.h" @@ -52,8 +53,9 @@ void usage() { ARGUMENTS + Argument ("fixel_in", "the input fixel data file (within the fixel directory)").type_image_in () + Argument ("tracks", "the input track file").type_tracks_in () - + Argument ("tsf", "the output track scalar file").type_file_out (); - + + Argument ("tsf", "the output track scalar file (.tsf), " + "or a bare field name (no extension) to embed as a dpv field " + "directly in the input TRX file (TRX input only)").type_file_out (); OPTIONS + Option ("angle", "the max anglular threshold for computing correspondence" @@ -78,18 +80,25 @@ void run() { Fixel::find_directions_header(Fixel::get_fixel_directory(argument[0])).get_image().with_direct_io(); DWI::Tractography::Properties properties; - DWI::Tractography::Reader reader(argument[1], properties); + auto reader_ptr = DWI::Tractography::TRX::open_tractogram(argument[1], properties); properties.comments.push_back("Created using fixel2tsf"); properties.comments.push_back("Source fixel image: " + Path::basename(argument[0])); properties.comments.push_back("Source track file: " + Path::basename(argument[1])); - DWI::Tractography::ScalarWriter tsf_writer(argument[2], properties); + // Determine output mode: bare field name → embed as dpv in TRX input; otherwise write TSF. + const std::string out_arg(argument[2]); + const bool embed_dpv = DWI::Tractography::TRX::is_trx_field_name(argument[1], out_arg); + std::unique_ptr> tsf_writer; + if (!embed_dpv) + tsf_writer.reset(new DWI::Tractography::ScalarWriter(out_arg, properties)); const float angular_threshold = get_option_value("angle", DWI::Tractography::Mapping::default_streamline2fixel_angle); const float angular_threshold_dp = cos(angular_threshold * (Math::pi / 180.0)); const size_t num_tracks = properties["count"].empty() ? 0 : to(properties["count"]); + std::vector dpv_values; + DWI::Tractography::Mapping::TrackMapperBase mapper(in_index_image); mapper.set_use_precise_mapping(true); @@ -101,7 +110,7 @@ void run() { Eigen::Vector3d voxel_pos_float; Eigen::Vector3i voxel_pos_int; - while (reader(tck)) { + while ((*reader_ptr)(tck)) { SetVoxelDir dixels; mapper(tck, dixels); scalars.clear(); @@ -135,10 +144,7 @@ void run() { if (largest_dp > angular_threshold_dp) { in_data_image.index(0) = offset + closest_fixel_index; const float value = in_data_image.value(); - if (std::isfinite(value)) - scalars[p] = in_data_image.value(); - else - scalars[p] = 0.0f; + scalars[p] = std::isfinite(value) ? value : 0.0f; } else { scalars[p] = 0.0f; } @@ -146,7 +152,15 @@ void run() { } } } - tsf_writer(scalars); + if (tsf_writer) + (*tsf_writer)(scalars); + if (embed_dpv) + dpv_values.insert(dpv_values.end(), scalars.begin(), scalars.end()); progress++; } + + if (embed_dpv) { + reader_ptr.reset(); // release TRX mmap before modifying the same zip file + DWI::Tractography::TRX::append_dpv(std::string(argument[1]), out_arg, dpv_values); + } } diff --git a/cpp/cmd/fixelconnectivity.cpp b/cpp/cmd/fixelconnectivity.cpp index 53a01d89bb..ea8a0ae0a6 100644 --- a/cpp/cmd/fixelconnectivity.cpp +++ b/cpp/cmd/fixelconnectivity.cpp @@ -48,7 +48,7 @@ void usage() { + Argument("fixel_directory", "the directory containing the fixels between which connectivity will be quantified").type_directory_in() + Argument("tracks", - "the tracks used to determine fixel-fixel connectivity").type_tracks_in() + "the tracks used to determine fixel-fixel connectivity").type_tracks_in().type_directory_in() + Argument("matrix", "the output fixel-fixel connectivity matrix directory path").type_directory_out(); diff --git a/cpp/cmd/tck2fixel.cpp b/cpp/cmd/tck2fixel.cpp index 1aff454bed..936d995200 100644 --- a/cpp/cmd/tck2fixel.cpp +++ b/cpp/cmd/tck2fixel.cpp @@ -27,6 +27,7 @@ #include "dwi/tractography/mapping/mapper.h" #include "dwi/tractography/mapping/mapping.h" #include "dwi/tractography/mapping/writer.h" +#include "dwi/tractography/trx_utils.h" using namespace MR; using namespace App; @@ -92,7 +93,7 @@ void usage() { SYNOPSIS = "Compute a fixel TDI map from a tractogram"; ARGUMENTS - + Argument ("tracks", "the input tracks.").type_tracks_in() + + Argument ("tracks", "the input tracks.").type_tracks_in().type_directory_in() + Argument ("fixel_folder_in", "the input fixel folder;" " used to define the fixels and their directions").type_directory_in() + Argument ("fixel_folder_out", "the fixel folder to which the output will be written;" @@ -151,7 +152,7 @@ void run() { std::vector fixel_TDI(num_fixels, 0.0); const std::string track_filename = argument[0]; DWI::Tractography::Properties properties; - DWI::Tractography::Reader track_file(track_filename, properties); + auto track_reader = DWI::Tractography::TRX::open_tractogram(track_filename, properties); // Read in tracts, and compute whole-brain fixel-fixel connectivity const size_t num_tracks = properties["count"].empty() ? 0 : to(properties["count"]); if (!num_tracks) @@ -159,7 +160,7 @@ void run() { { using SetVoxelDir = DWI::Tractography::Mapping::SetVoxelDir; - DWI::Tractography::Mapping::TrackLoader loader(track_file, num_tracks, "mapping tracks to fixels"); + DWI::Tractography::Mapping::TrackLoader loader(*track_reader, num_tracks, "mapping tracks to fixels"); DWI::Tractography::Mapping::TrackMapperBase mapper(index_image); mapper.set_upsample_ratio(DWI::Tractography::Mapping::determine_upsample_ratio(index_header, properties, 0.333f)); mapper.set_use_precise_mapping(true); @@ -170,8 +171,6 @@ void run() { Thread::batch(SetVoxelDir()), tract_processor); } - track_file.close(); - Header output_header(Fixel::data_header_from_index(index_image)); write_fixel_output(Path::join(output_fixel_folder, argument[3]), fixel_TDI, output_header); diff --git a/cpp/cmd/tckconvert.cpp b/cpp/cmd/tckconvert.cpp index 11a41de462..0fb2ad80ba 100644 --- a/cpp/cmd/tckconvert.cpp +++ b/cpp/cmd/tckconvert.cpp @@ -16,14 +16,17 @@ #include #include -#include +#include +#include #include "command.h" #include "dwi/tractography/file.h" #include "dwi/tractography/properties.h" +#include "dwi/tractography/trx_utils.h" #include "file/matrix.h" #include "file/name_parser.h" #include "file/ofstream.h" +#include "file/path.h" #include "raw.h" using namespace MR; @@ -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,29 @@ 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() + + + Option ("positions_datatype", + "datatype of the positions array in the output TRX file (float16, float32, or float64). " + "Only applies to TRX output. Default: preserves the source datatype for TRX→TRX conversion; " + "float32 for all other input formats.") + + Argument ("spec").type_text(); } // clang-format on @@ -329,6 +364,56 @@ 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 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 PLYWriter : public WriterInterface { public: PLYWriter(std::string_view file, @@ -680,15 +765,127 @@ class RibWriter : public WriterInterface { }; void run() { + const bool input_is_trx = TRX::is_trx(argument[0]); + 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 { + trx = TRX::load_trx(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::TrxSaveOptions save_opts; + save_opts.compression_standard = ZIP_CM_STORE; + { + auto opt = get_options("positions_datatype"); + if (!opt.empty()) { + const std::string spec = opt[0][0]; + if (spec == "float16") + save_opts.positions_dtype = trx::TrxScalarType::Float16; + else if (spec == "float32") + save_opts.positions_dtype = trx::TrxScalarType::Float32; + else if (spec == "float64") + save_opts.positions_dtype = trx::TrxScalarType::Float64; + else + throw Exception("Unknown -positions_datatype '" + spec + "'; expected float16, float32, or float64"); + } + } + trx->save(trx_save_path, save_opts); + 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; + TRX::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 TRX::TRXReader(argument[0]); + reader.reset(trx_reader_ptr); + trx_reader = trx_reader_ptr; } else { throw Exception("Unsupported input file type."); } @@ -709,40 +906,49 @@ 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 TRX::TRXReader(argument[0])); + } else { + throw Exception("Unsupported input file type."); + } + while ((*count_reader)(count_tck)) { + nb_streamlines++; + nb_vertices += count_tck.size(); + } + auto *trx_writer = new TRX::TRXWriter(trx_save_path, nb_streamlines, nb_vertices, trx_output, rename_trx_directory); + try { + for (const auto &spec : dps_specs) + trx_writer->get_trx()->add_dps_from_text(spec.name, spec.dtype, spec.path); + for (const auto &spec : dpv_specs) + trx_writer->get_trx()->add_dpv_from_tsf(spec.name, spec.dtype, spec.path); + } catch (const std::exception &e) { + throw Exception(e.what()); + } + // Copy TCK properties into the TRX header as "metadata" + if (!properties.empty()) { + json::object meta; + for (const auto &[key, val] : properties) + meta[key] = json(val); + trx_writer->get_trx()->header = trx::_json_set(trx_writer->get_trx()->header, "metadata", json(meta)); + } + writer.reset(trx_writer); } 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/cpp/cmd/tckdfc.cpp b/cpp/cmd/tckdfc.cpp index 5d88ab6387..aa123f63e9 100644 --- a/cpp/cmd/tckdfc.cpp +++ b/cpp/cmd/tckdfc.cpp @@ -23,8 +23,8 @@ #include "thread_queue.h" #include "transform.h" -#include "dwi/tractography/file.h" #include "dwi/tractography/properties.h" +#include "dwi/tractography/trx_utils.h" #include "dwi/tractography/mapping/loader.h" #include "dwi/tractography/mapping/mapper.h" @@ -38,6 +38,7 @@ using namespace App; using namespace MR::DWI; using namespace MR::DWI::Tractography; using namespace MR::DWI::Tractography::Mapping; +using namespace MR::DWI::Tractography::TRX; const std::vector windows = {"rectangle", "triangle", "cosine", "hann", "hamming", "lanczos"}; @@ -85,7 +86,7 @@ void usage () { " in search of a valid timeseries to sample from the input image."; ARGUMENTS - + Argument ("tracks", "the input track file.").type_file_in() + + Argument ("tracks", "the input track file.").type_tracks_in().type_directory_in() + Argument ("fmri", "the pre-processed fMRI time series").type_image_in() + Argument ("output", "the output TW-dFC image").type_image_out(); @@ -286,8 +287,7 @@ void run() { Tractography::Properties properties; { // Just get the properties for now; will re-instantiate the reader multiple times later - // TODO Constructor for properties using the file path? - Tractography::Reader tck_file(tck_path, properties); + TRX::open_tractogram(tck_path, properties); } const size_t num_tracks = properties["count"].empty() ? 0 : to(properties["count"]); @@ -356,8 +356,8 @@ void run() { if (is_static) { - Tractography::Reader tck_file(tck_path, properties); - Mapping::TrackLoader loader(tck_file, num_tracks, "Generating (static) TW-dFC image"); + auto tck_reader = TRX::open_tractogram(tck_path, properties); + Mapping::TrackLoader loader(*tck_reader, num_tracks, "Generating (static) TW-dFC image"); Mapping::TrackMapperTWI mapper(H_3D, contrast_t::SCALAR_MAP, tck_stat_t::ENDS_CORR); mapper.set_upsample_ratio(upsample_ratio); mapper.add_twdfc_static_image(fmri_image); @@ -374,8 +374,8 @@ void run() { Image counts; if (stat_vox == vox_stat_t::MEAN) { counts = Image::scratch(H_3D, "Track count scratch buffer"); - Tractography::Reader tck_file(tck_path, properties); - Mapping::TrackLoader loader(tck_file, num_tracks, "Calculating initial TDI"); + auto tck_reader = TRX::open_tractogram(tck_path, properties); + Mapping::TrackLoader loader(*tck_reader, num_tracks, "Calculating initial TDI"); Mapping::TrackMapperBase mapper(H_3D); mapper.set_upsample_ratio(upsample_ratio); Count_receiver receiver(counts); @@ -392,8 +392,8 @@ void run() { { LogLevelLatch latch(0); - Tractography::Reader tck_file(tck_path, properties); - Mapping::TrackLoader loader(tck_file); + auto tck_reader = TRX::open_tractogram(tck_path, properties); + Mapping::TrackLoader loader(*tck_reader); Mapping::TrackMapperTWI mapper(H_3D, contrast_t::SCALAR_MAP, tck_stat_t::ENDS_CORR); mapper.set_upsample_ratio(upsample_ratio); mapper.add_twdfc_dynamic_image(fmri_image, window, timepoint); diff --git a/cpp/cmd/tckedit.cpp b/cpp/cmd/tckedit.cpp index 411e468491..993042c8be 100644 --- a/cpp/cmd/tckedit.cpp +++ b/cpp/cmd/tckedit.cpp @@ -20,11 +20,13 @@ #include "exception.h" #include "mrtrix.h" #include "ordered_thread_queue.h" +#include "progressbar.h" #include "types.h" #include "dwi/tractography/file.h" #include "dwi/tractography/properties.h" #include "dwi/tractography/roi.h" +#include "dwi/tractography/trx_utils.h" #include "dwi/tractography/weights.h" #include "dwi/tractography/editing/editing.h" @@ -89,8 +91,8 @@ void usage() { ARGUMENTS - + Argument ("tracks_in", "the input track file(s)").type_tracks_in().allow_multiple() - + Argument ("tracks_out", "the output track file").type_tracks_out(); + + Argument ("tracks_in", "the input track file(s)").type_tracks_in().type_file_in().type_directory_in().allow_multiple() + + Argument ("tracks_out", "the output track file").type_file_out(); OPTIONS + ROIOption @@ -124,11 +126,118 @@ void erase_if_present(Tractography::Properties &p, const std::string s) { p.erase(i); } +// Receiver for TRX mask mode: pushes cropped streamline segments into a TrxStream. +// Used when -mask is specified; metadata (dps/dpv/groups) is not preserved since +// mask cropping changes streamline topology. +class TRXStreamReceiver { +public: + TRXStreamReceiver(trx::TrxStream &stream, const size_t n, const size_t s) + : stream(stream), number(n), skip(s), count(0), total_count(0), progress(" 0 read, 0 written") {} + + TRXStreamReceiver(const TRXStreamReceiver &) = delete; + + ~TRXStreamReceiver() { + progress.set_text(str(total_count) + " read, " + str(count) + " written"); + if (number && count != number) + WARN("User requested " + str(number) + " streamlines, but only " + str(count) + " were written to file"); + } + + bool operator()(const Streamline<> &in) { + if (number && count == number) + return false; + ++total_count; + if (in.empty()) + return true; + if (in[0].allFinite()) { + // Single unbroken streamline + if (skip) { + --skip; + return true; + } + push_segment(in); + ++count; + } else { + // Mask-split: NaN-delimited segments — replicate Receiver's behaviour + Streamline<> seg; + for (const auto &p : in) { + if (p.allFinite()) { + seg.push_back(p); + } else if (!seg.empty()) { + push_segment(seg); + seg.clear(); + } + } + ++count; + } + return !(number && count == number); + } + +private: + void push_segment(const Streamline<> &seg) { + std::vector> pts; + pts.reserve(seg.size()); + for (const auto &p : seg) + pts.push_back({p[0], p[1], p[2]}); + stream.push_streamline(pts); + } + + trx::TrxStream &stream; + const size_t number; + size_t skip, count, total_count; + ProgressBar progress; +}; + +// Receiver for TRX mode: collects original streamline indices instead of writing to file +class TRXIndexCollector { +public: + TRXIndexCollector(const size_t n, const size_t s) + : number(n), skip(s), count(0), total_count(0), progress(" 0 read, 0 written") {} + + TRXIndexCollector(const TRXIndexCollector &) = delete; + + ~TRXIndexCollector() { + progress.set_text(str(total_count) + " read, " + str(count) + " written"); + if (number && count != number) + WARN("User requested " + str(number) + " streamlines, but only " + str(count) + " were written to file"); + } + + bool operator()(const Streamline<> &in) { + if (number && count == number) + return false; + ++total_count; + if (in.empty()) + return true; + if (skip) { + --skip; + return true; + } + indices.push_back(static_cast(in.get_index())); + ++count; + return !(number && count == number); + } + + const std::vector &get_indices() const { return indices; } + +private: + const size_t number; + size_t skip, count, total_count; + std::vector indices; + ProgressBar progress; +}; + void run() { const size_t num_inputs = argument.size() - 1; const std::string output_path = argument[num_inputs]; + const bool trx_out = TRX::is_trx(output_path); + const bool trx_in = (num_inputs == 1) && TRX::is_trx(std::string(argument[0])); + + if (trx_out && !trx_in) + throw Exception("TRX output requires a single TRX input file"); + if (trx_in && !trx_out) + throw Exception("TRX input requires TRX output to preserve metadata; use .trx extension for output"); + // Make sure configuration is sensible if (!get_options("tck_weights_in").empty() && num_inputs > 1) throw Exception("Cannot use per-streamline weighting with multiple input files"); @@ -138,46 +247,51 @@ void run() { size_t count = 0; std::vector input_file_list; - for (size_t file_index = 0; file_index != num_inputs; ++file_index) { + if (trx_in) { + input_file_list.push_back(std::string(argument[0])); + count = TRX::count_trx(std::string(argument[0])).first; + } else { + for (size_t file_index = 0; file_index != num_inputs; ++file_index) { - input_file_list.push_back(argument[file_index]); + input_file_list.push_back(argument[file_index]); - Properties p; - Reader(argument[file_index], p); + Properties p; + Reader(argument[file_index], p); - for (const auto &i : p.comments) { - bool present = false; - for (const auto &j : properties.comments) - if ((present = (i == j))) - break; - if (!present) - properties.comments.push_back(i); - } + for (const auto &i : p.comments) { + bool present = false; + for (const auto &j : properties.comments) + if ((present = (i == j))) + break; + if (!present) + properties.comments.push_back(i); + } - for (const auto &i : p.prior_rois) { - const auto potential_matches = properties.prior_rois.equal_range(i.first); - bool present = false; - for (auto j = potential_matches.first; !present && j != potential_matches.second; ++j) - present = (i.second == j->second); - if (!present) - properties.prior_rois.insert(i); - } + for (const auto &i : p.prior_rois) { + const auto potential_matches = properties.prior_rois.equal_range(i.first); + bool present = false; + for (auto j = potential_matches.first; !present && j != potential_matches.second; ++j) + present = (i.second == j->second); + if (!present) + properties.prior_rois.insert(i); + } - size_t this_count = 0; - - for (const auto &i : p) { - if (i.first == "count") { - this_count = to(i.second); - } else { - auto existing = properties.find(i.first); - if (existing == properties.end()) - properties.insert(i); - else if (i.second != existing->second) - existing->second = "variable"; + size_t this_count = 0; + + for (const auto &i : p) { + if (i.first == "count") { + this_count = to(i.second); + } else { + auto existing = properties.find(i.first); + if (existing == properties.end()) + properties.insert(i); + else if (i.second != existing->second) + existing->second = "variable"; + } } - } - count += this_count; + count += this_count; + } } DEBUG("estimated number of input tracks: " + str(count)); @@ -208,10 +322,40 @@ void run() { const size_t number = get_option_value("number", size_t(0)); const size_t skip = get_option_value("skip", size_t(0)); - Loader loader(input_file_list); - Worker worker(properties, inverse, ends_only); - Receiver receiver(output_path, properties, number, skip); - - Thread::run_ordered_queue( - loader, Thread::batch(Streamline<>()), Thread::multi(worker), Thread::batch(Streamline<>()), receiver); + if (trx_out) { + TRX::TRXReader trx_loader(input_file_list[0]); + Worker worker(properties, inverse, ends_only); + if (properties.mask.size()) { + // -mask crops streamlines at vertex level: use TrxStream to write cropped geometry. + // dps/dpv/groups cannot be preserved as the streamline-to-vertex mapping changes. + // TRX coordinates are already RAS+, so no affine is needed on the stream. + { + auto input_trx = TRX::load_trx(input_file_list[0]); + if (TRX::has_aux_data(input_trx.get())) + WARN("TRX metadata (dps/dpv/groups) is not preserved when -mask is used," + " as mask cropping changes streamline topology"); + } + trx::TrxStream stream; + TRXStreamReceiver stream_receiver(stream, number, skip); + Thread::run_ordered_queue(trx_loader, + Thread::batch(Streamline<>()), + Thread::multi(worker), + Thread::batch(Streamline<>()), + stream_receiver); + stream.finalize(output_path); + } else { + TRXIndexCollector collector(number, skip); + Thread::run_ordered_queue( + trx_loader, Thread::batch(Streamline<>()), Thread::multi(worker), Thread::batch(Streamline<>()), collector); + auto input_trx = TRX::load_trx(input_file_list[0]); + auto output_trx = input_trx->subset_streamlines(collector.get_indices()); + output_trx->save(output_path); + } + } else { + Loader loader(input_file_list); + Worker worker(properties, inverse, ends_only); + Receiver receiver(output_path, properties, number, skip); + Thread::run_ordered_queue( + loader, Thread::batch(Streamline<>()), Thread::multi(worker), Thread::batch(Streamline<>()), receiver); + } } diff --git a/cpp/cmd/tckgen.cpp b/cpp/cmd/tckgen.cpp index 7f4ade1d71..e5c6eb5635 100644 --- a/cpp/cmd/tckgen.cpp +++ b/cpp/cmd/tckgen.cpp @@ -236,6 +236,12 @@ void usage() { + Option ("output_stats", "output statistics on streamline generation to a JSON file") + Argument ("path").type_file_out() + + OptionGroup("TRX output options") + + Option ("trx_float16", + "when writing TRX output, store streamline coordinates as float16 instead of the default float32." + " Reduces file size by ~50%% at the cost of ~0.02-0.05 mm coordinate precision." + " Has no effect when writing TCK output.") + + DWI::GradImportOptions(); } @@ -266,6 +272,9 @@ void run() { if (!opt.empty()) properties["seed_output"] = std::string(opt[0][0]); + if (!get_options("trx_float16").empty()) + properties["trx_positions_dtype"] = "float16"; + // load ROIs and tractography specific options // NB must occur before seed check below due to -select option override Tracking::load_streamline_properties_and_rois(properties); diff --git a/cpp/cmd/tckinfo.cpp b/cpp/cmd/tckinfo.cpp index d364d14e07..d1b7c4367c 100644 --- a/cpp/cmd/tckinfo.cpp +++ b/cpp/cmd/tckinfo.cpp @@ -17,6 +17,7 @@ #include "command.h" #include "dwi/tractography/file.h" #include "dwi/tractography/properties.h" +#include "dwi/tractography/trx_utils.h" #include "file/ofstream.h" #include "progressbar.h" @@ -32,24 +33,40 @@ void usage() { SYNOPSIS = "Print out information about a track file"; ARGUMENTS - + Argument ("tracks", "the input track file.").type_tracks_in().allow_multiple(); + + Argument ("tracks", "the input track file.").type_tracks_in().type_file_in().type_directory_in().allow_multiple(); OPTIONS - + Option ("count", "count number of tracks in file explicitly, ignoring the header"); + + Option ("count", "count number of tracks in file explicitly, ignoring the header") + + Option ("prefix_depth", "for TRX files, collapse groups by the first N underscore-delimited " + "tokens of their name. Defaults to 1, which groups by atlas name prefix. " + "Use 0 to list all groups individually, or higher values for finer detail.") + + Argument ("N").type_integer(0); } // clang-format on void run() { const bool actual_count = !get_options("count").empty(); + const auto prefix_depth_opt = get_options("prefix_depth"); + const bool prefix_depth_specified = !prefix_depth_opt.empty(); + const int prefix_depth = prefix_depth_specified ? int(prefix_depth_opt[0][0]) : 1; for (size_t i = 0; i < argument.size(); ++i) { - Tractography::Properties properties; - Tractography::Reader file(argument[i], properties); - std::cout << "***********************************\n"; std::cout << " Tracks file: \"" << argument[i] << "\"\n"; + if (Tractography::TRX::is_trx(argument[i])) { + auto trx = Tractography::TRX::load_trx_header_only(argument[i]); + if (!trx) + throw Exception("Failed to load TRX file: " + std::string(argument[i])); + Tractography::TRX::print_info(std::cout, *trx, prefix_depth, !prefix_depth_specified); + trx->close(); + continue; + } + + Tractography::Properties properties; + Tractography::Reader file(argument[i], properties); + for (Tractography::Properties::iterator i = properties.begin(); i != properties.end(); ++i) { std::string S(i->first + ':'); S.resize(22, ' '); diff --git a/cpp/cmd/tckmap.cpp b/cpp/cmd/tckmap.cpp index 1321b610ae..d8c2594d92 100644 --- a/cpp/cmd/tckmap.cpp +++ b/cpp/cmd/tckmap.cpp @@ -24,8 +24,8 @@ #include "types.h" #include "dwi/gradient.h" -#include "dwi/tractography/file.h" #include "dwi/tractography/properties.h" +#include "dwi/tractography/trx_utils.h" #include "dwi/tractography/weights.h" #include "dwi/tractography/mapping/loader.h" @@ -43,6 +43,7 @@ using namespace App; using namespace MR::DWI; using namespace MR::DWI::Tractography; using namespace MR::DWI::Tractography::Mapping; +using namespace MR::DWI::Tractography::TRX; // clang-format off const OptionGroup OutputHeaderOption = OptionGroup ("Options for the header of the output image") @@ -211,7 +212,7 @@ void usage () { "NeuroImage, 2013, 67, 298-312 (Appendix 3)"; ARGUMENTS - + Argument ("tracks", "the input track file.").type_file_in() + + Argument ("tracks", "the input track file.").type_tracks_in().type_directory_in() + Argument ("output", "the output track-weighted image").type_image_out(); OPTIONS @@ -260,7 +261,11 @@ DataType determine_datatype(const DataType current_dt, void run() { Tractography::Properties properties; - Tractography::Reader file(argument[0], properties); + // Resolve per-streamline weights: for TCK, Reader handles -tck_weights_in itself; + // for TRX, open_tractogram (3-arg) injects weights from a dps field or external file. + auto wt_opt = get_options("tck_weights_in"); + const std::string weight_src = wt_opt.empty() ? "" : std::string(wt_opt[0][0]); + auto reader = open_tractogram(argument[0], properties, weight_src); const size_t num_tracks = properties["count"].empty() ? 0 : to(properties["count"]); @@ -519,7 +524,7 @@ void run() { INFO(msg); // Start initialising members for multi-threaded calculation - TrackLoader loader(file, num_tracks); + TrackLoader loader(*reader, num_tracks); std::unique_ptr mapper((stat_tck == tck_stat_t::GAUSSIAN) ? (new Gaussian::TrackMapper(header, contrast)) diff --git a/cpp/cmd/tckresample.cpp b/cpp/cmd/tckresample.cpp index 8ff386d8c0..9e2c725707 100644 --- a/cpp/cmd/tckresample.cpp +++ b/cpp/cmd/tckresample.cpp @@ -24,6 +24,7 @@ #include "dwi/tractography/resampling/fixed_step_size.h" #include "dwi/tractography/resampling/resampling.h" #include "dwi/tractography/resampling/upsampler.h" +#include "dwi/tractography/trx_utils.h" #include "image.h" #include "math/math.h" #include "ordered_thread_queue.h" @@ -54,10 +55,15 @@ void usage() { " the magnitude of the difference will typically depend on" " the discrepancy in the number of vertices," " with less vertices leading to a shorter length" - " (due to taking chordal lengths of curved trajectories)."; + " (due to taking chordal lengths of curved trajectories)." + + + "When the input is a TRX file and the output is also TRX," + " per-streamline data (dps) and groups are preserved." + " Per-vertex data (dpv) cannot be preserved because resampling changes" + " the number of vertices per streamline and will be discarded with a warning."; ARGUMENTS - + Argument ("in_tracks", "the input track file").type_tracks_in() + + Argument ("in_tracks", "the input track file").type_tracks_in().type_directory_in() + Argument ("out_tracks", "the output resampled tracks").type_tracks_out(); OPTIONS @@ -87,34 +93,76 @@ class Worker { class Receiver { public: Receiver(std::string_view path, const Properties &properties) - : writer(path, properties), progress("resampling streamlines") {} + : output_path(path), progress("resampling streamlines"), count_(0) { + if (TRX::is_trx(path)) { + trx_stream = std::make_unique("float32"); + } else { + tck_writer = std::make_unique>(path, properties); + } + } + + ~Receiver() { + if (trx_stream) { + try { + trx_stream->finalize(output_path, trx::TrxSaveOptions{}); + } catch (const std::exception &e) { + Exception(e.what()).display(); + App::exit_error_code = 1; + } + } + } bool operator()(const Streamline &tck) { - auto progress_message = [&]() { - return "resampling streamlines (count: " + str(writer.count) + - ", skipped: " + str(writer.total_count - writer.count) + ")"; - }; - writer(tck); - progress.set_text(progress_message()); + if (trx_stream) { + std::vector> pts(tck.size()); + for (size_t i = 0; i < tck.size(); ++i) + pts[i] = {tck[i][0], tck[i][1], tck[i][2]}; + trx_stream->push_streamline(pts); + ++count_; + progress.set_text("resampling streamlines (count: " + str(count_) + ")"); + } else { + auto progress_message = [&]() { + return "resampling streamlines (count: " + str(tck_writer->count) + + ", skipped: " + str(tck_writer->total_count - tck_writer->count) + ")"; + }; + (*tck_writer)(tck); + progress.set_text(progress_message()); + } return true; } private: - Writer writer; + std::string output_path; ProgressBar progress; + size_t count_; + std::unique_ptr trx_stream; + std::unique_ptr> tck_writer; }; void run() { Properties properties; - Reader read(argument[0], properties); + auto reader = TRX::open_tractogram(argument[0], properties); + + const bool trx_to_trx = TRX::is_trx(argument[0]) && TRX::is_trx(argument[1]); + if (trx_to_trx) { + auto src = TRX::load_trx(argument[0]); + if (src && !src->data_per_vertex.empty()) + WARN(str(src->data_per_vertex.size()) + " per-vertex data field(s) will be discarded:" + " vertex count changes after resampling"); + } const std::unique_ptr resampler(Resampling::get_resampler()); Worker worker(resampler); - Receiver receiver(argument[1], properties); - Thread::run_ordered_queue(read, - Thread::batch(Streamline()), - Thread::multi(worker), - Thread::batch(Streamline()), - receiver); + { + Receiver receiver(argument[1], properties); + Thread::run_ordered_queue(*reader, + Thread::batch(Streamline()), + Thread::multi(worker), + Thread::batch(Streamline()), + receiver); + } // receiver destructs here: TrxStream::finalize() creates the output TRX file + + if (trx_to_trx) + TRX::copy_trx_sidecar_data(argument[0], argument[1], false); } diff --git a/cpp/cmd/tcksample.cpp b/cpp/cmd/tcksample.cpp index 75a25d9253..5b37c1e3e0 100644 --- a/cpp/cmd/tcksample.cpp +++ b/cpp/cmd/tcksample.cpp @@ -19,6 +19,7 @@ #include "dwi/tractography/mapping/mapper.h" #include "dwi/tractography/properties.h" #include "dwi/tractography/scalar_file.h" +#include "dwi/tractography/trx_utils.h" #include "file/matrix.h" #include "file/ofstream.h" #include "file/path.h" @@ -58,7 +59,9 @@ void usage() { ARGUMENTS + Argument ("tracks", "the input track file").type_tracks_in() + Argument ("image", "the image to be sampled").type_image_in() - + Argument ("values", "the output sampled values").type_file_out(); + + Argument ("values", "the output sampled values (.tsf for per-vertex, text for per-streamline statistics), " + "or a bare field name (no extension) to embed directly in the input TRX file " + "(TRX input only; dpv when no -stat_tck, dps when -stat_tck is given)").type_file_out(); OPTIONS + Option ("stat_tck", "compute some statistic from the values along each streamline;" @@ -331,7 +334,15 @@ class Receiver_Statistic : private ReceiverBase { return true; } - void save(std::string_view path) { File::Matrix::save_vector(vector_data, path); } + void save(std::string_view path) { + if (!path.empty()) + File::Matrix::save_vector(vector_data, path); + } + + void append_to_trx(const std::string &trx_path, const std::string &name) const { + std::vector vals(vector_data.data(), vector_data.data() + vector_data.size()); + DWI::Tractography::TRX::append_dps(trx_path, name, vals); + } private: vector_type vector_data; @@ -339,9 +350,15 @@ class Receiver_Statistic : private ReceiverBase { class Receiver_NoStatistic : private ReceiverBase { public: - Receiver_NoStatistic(std::string_view path, const size_t num_tracks, const DWI::Tractography::Properties &properties) - : ReceiverBase(num_tracks) { - if (Path::has_suffix(path, ".tsf")) { + Receiver_NoStatistic(std::string_view path, + const size_t num_tracks, + const DWI::Tractography::Properties &properties, + bool collect_dpv = false) + : ReceiverBase(num_tracks), collect_dpv_(collect_dpv) { + if (path.empty()) { + // Field-only mode: no file output; dpv collection is implicit + collect_dpv_ = true; + } else if (Path::has_suffix(path, ".tsf")) { tsf.reset(new DWI::Tractography::ScalarWriter(path, properties)); } else { ascii.reset(new File::OFStream(path)); @@ -361,53 +378,72 @@ class Receiver_NoStatistic : private ReceiverBase { (*ascii) << " " << *i; } (*ascii) << "\n"; - } else { + } else if (tsf) { (*tsf)(in); } + // else: field-only mode (path empty, no file output; dpv_accum_ collects below) + if (collect_dpv_) + dpv_accum_.insert(dpv_accum_.end(), in.begin(), in.end()); ++(*this); return true; } + void append_to_trx(const std::string &trx_path, const std::string &name) const { + DWI::Tractography::TRX::append_dpv(trx_path, name, dpv_accum_); + } + private: std::unique_ptr ascii; std::unique_ptr> tsf; + bool collect_dpv_; + std::vector dpv_accum_; }; template -void execute_nostat(DWI::Tractography::Reader &reader, +void execute_nostat(std::unique_ptr> &reader_owner, const DWI::Tractography::Properties &properties, const size_t num_tracks, Image &image, - std::string_view path) { + std::string_view path, + const std::string &trx_path = "", + const std::string &trx_name = "") { SamplerNonPrecise sampler(image, stat_tck::NONE, Image()); - Receiver_NoStatistic receiver(path, num_tracks, properties); - Thread::run_ordered_queue(reader, + Receiver_NoStatistic receiver(path, num_tracks, properties, !trx_path.empty()); + Thread::run_ordered_queue(*reader_owner, Thread::batch(DWI::Tractography::Streamline()), Thread::multi(sampler), Thread::batch(DWI::Tractography::TrackScalar()), receiver); + reader_owner.reset(); // release TRX mmap before modifying the same zip file + if (!trx_path.empty()) + receiver.append_to_trx(trx_path, trx_name); } template -void execute(DWI::Tractography::Reader &reader, +void execute(std::unique_ptr> &reader_owner, const size_t num_tracks, Image &image, const stat_tck statistic, Image &tdi, - std::string_view path) { + std::string_view path, + const std::string &trx_path = "", + const std::string &trx_name = "") { SamplerType sampler(image, statistic, tdi); Receiver_Statistic receiver(num_tracks); - Thread::run_ordered_queue(reader, + Thread::run_ordered_queue(*reader_owner, Thread::batch(DWI::Tractography::Streamline()), Thread::multi(sampler), Thread::batch(std::pair()), receiver); + reader_owner.reset(); // release TRX mmap before modifying the same zip file receiver.save(path); + if (!trx_path.empty()) + receiver.append_to_trx(trx_path, trx_name); } void run() { DWI::Tractography::Properties properties; - DWI::Tractography::Reader reader(argument[0], properties); + auto reader_ptr = DWI::Tractography::TRX::open_tractogram(argument[0], properties); auto H = Header::open(argument[1]); auto image = H.get_image(); @@ -424,11 +460,22 @@ void run() { if (statistic == stat_tck::NONE && interp == interp_type::PRECISE) throw Exception("Precise streamline mapping may only be used with per-streamline statistics"); + // If the output argument is a bare name (no extension) and input is TRX, + // embed the result as a dps or dpv field; otherwise write to the given path. + std::string trx_path, trx_name; + std::string output_path(argument[2]); + if (DWI::Tractography::TRX::is_trx_field_name(argument[0], output_path)) { + trx_path = std::string(argument[0]); + trx_name = output_path; + output_path = ""; // suppress file write + } + Image tdi; if (!get_options("use_tdi_fraction").empty()) { if (statistic == stat_tck::NONE) throw Exception("Cannot use -use_tdi_fraction option unless a per-streamline statistic is used"); - DWI::Tractography::Reader tdi_reader(argument[0], properties); + auto tdi_reader_ptr = DWI::Tractography::TRX::open_tractogram(argument[0], properties); + auto &tdi_reader = *tdi_reader_ptr; DWI::Tractography::Mapping::TrackMapperBase mapper(H); mapper.set_use_precise_mapping(interp == interp_type::PRECISE); tdi = Image::scratch(H, "TDI scratch image"); @@ -443,10 +490,12 @@ void run() { if (statistic == stat_tck::NONE) { switch (interp) { case interp_type::NEAREST: - execute_nostat>>(reader, properties, num_tracks, image, argument[2]); + execute_nostat>>( + reader_ptr, properties, num_tracks, image, output_path, trx_path, trx_name); break; case interp_type::LINEAR: - execute_nostat>>(reader, properties, num_tracks, image, argument[2]); + execute_nostat>>( + reader_ptr, properties, num_tracks, image, output_path, trx_path, trx_name); break; case interp_type::PRECISE: throw Exception("Precise streamline mapping may only be used with per-streamline statistics"); @@ -455,14 +504,14 @@ void run() { switch (interp) { case interp_type::NEAREST: execute>>>( - reader, num_tracks, image, statistic, tdi, argument[2]); + reader_ptr, num_tracks, image, statistic, tdi, output_path, trx_path, trx_name); break; case interp_type::LINEAR: execute>>>( - reader, num_tracks, image, statistic, tdi, argument[2]); + reader_ptr, num_tracks, image, statistic, tdi, output_path, trx_path, trx_name); break; case interp_type::PRECISE: - execute(reader, num_tracks, image, statistic, tdi, argument[2]); + execute(reader_ptr, num_tracks, image, statistic, tdi, output_path, trx_path, trx_name); break; } } diff --git a/cpp/cmd/tcksift.cpp b/cpp/cmd/tcksift.cpp index b63febd304..59da9b7080 100644 --- a/cpp/cmd/tcksift.cpp +++ b/cpp/cmd/tcksift.cpp @@ -25,6 +25,8 @@ #include "dwi/tractography/SIFT/proc_mask.h" #include "dwi/tractography/SIFT/sift.h" #include "dwi/tractography/SIFT/sifter.h" +#include "dwi/tractography/trx_utils.h" +#include "file/path.h" using namespace MR; using namespace App; @@ -48,10 +50,10 @@ void usage() { "NeuroImage, 2013, 67, 298-312"; ARGUMENTS - + Argument ("in_tracks", "the input track file").type_tracks_in() + + Argument ("in_tracks", "the input track file").type_tracks_in().type_file_in().type_directory_in() + Argument ("in_fod", "input image containing the spherical harmonics" " of the fibre orientation distributions").type_image_in() - + Argument ("out_tracks", "the output filtered tracks file").type_tracks_out(); + + Argument ("out_tracks", "the output filtered tracks file").type_file_out(); OPTIONS @@ -128,7 +130,18 @@ void run() { if (!debug_path.empty()) sifter.output_all_debug_images(debug_path, "after"); - sifter.output_filtered_tracks(argument[0], argument[2]); + const std::string out_path(argument[2]); + if (Tractography::TRX::is_trx(out_path)) { + const std::string in_path(argument[0]); + if (!Tractography::TRX::is_trx(in_path)) + throw Exception("TRX output requires TRX input (input is not a TRX file)"); + auto input_trx = Tractography::TRX::load_trx(in_path); + auto selected = sifter.get_selected_indices(); + auto output_trx = input_trx->subset_streamlines(selected); + output_trx->save(out_path); + } else { + sifter.output_filtered_tracks(argument[0], argument[2]); + } opt = get_options("out_selection"); if (!opt.empty()) diff --git a/cpp/cmd/tcksift2.cpp b/cpp/cmd/tcksift2.cpp index 2bc624b259..107d7b044a 100644 --- a/cpp/cmd/tcksift2.cpp +++ b/cpp/cmd/tcksift2.cpp @@ -29,6 +29,7 @@ #include "dwi/tractography/SIFT/sift.h" #include "dwi/tractography/SIFT2/tckfactor.h" +#include "dwi/tractography/trx_utils.h" using namespace MR; using namespace App; @@ -125,7 +126,9 @@ void usage() { ARGUMENTS + Argument ("in_tracks", "the input track file").type_tracks_in() + Argument ("in_fod", "input image containing the spherical harmonics of the fibre orientation distributions").type_image_in() - + Argument ("out_weights", "output text file containing the weighting factor for each streamline").type_file_out(); + + Argument ("out_weights", "output text file containing the per-streamline weighting factor, " + "or a bare field name (no extension) to embed the weights as a dps field " + "directly in the input TRX file (TRX input only)").type_file_out(); OPTIONS @@ -222,7 +225,13 @@ void run() { tckfactor.report_entropy(); - tckfactor.output_factors(argument[2]); + const std::string out_weights(argument[2]); + if (Tractography::TRX::is_trx_field_name(argument[0], out_weights)) { + auto factors = tckfactor.get_factors(); + Tractography::TRX::append_dps(std::string(argument[0]), out_weights, factors); + } else { + tckfactor.output_factors(out_weights); + } auto opt = get_options("out_coeffs"); if (!opt.empty()) diff --git a/cpp/cmd/tckstats.cpp b/cpp/cmd/tckstats.cpp index 1e5fab5121..b12a1e3ba4 100644 --- a/cpp/cmd/tckstats.cpp +++ b/cpp/cmd/tckstats.cpp @@ -23,14 +23,15 @@ #include "math/median.h" -#include "dwi/tractography/file.h" #include "dwi/tractography/properties.h" +#include "dwi/tractography/trx_utils.h" #include "dwi/tractography/weights.h" using namespace MR; using namespace App; using namespace MR::DWI; using namespace MR::DWI::Tractography; +using namespace MR::DWI::Tractography::TRX; // TODO Make compatible with stats generic options? // - Some features would not be compatible due to potential presence of track weights @@ -45,7 +46,7 @@ void usage() { SYNOPSIS = "Calculate statistics on streamlines lengths"; ARGUMENTS - + Argument ("tracks_in", "the input track file").type_tracks_in(); + + Argument ("tracks_in", "the input track file").type_tracks_in().type_directory_in(); OPTIONS @@ -58,7 +59,9 @@ void usage() { + Option ("histogram", "output a histogram of streamline lengths") + Argument ("path").type_file_out() - + Option ("dump", "dump the streamlines lengths to a text file") + + Option ("dump", "dump the streamlines lengths to a text file," + " or embed them as a dps field in the input TRX file when a bare field name" + " (no extension) is given and the input is a TRX file") + Argument ("path").type_file_out() + Option ("ignorezero", "do not generate a warning if the track file contains streamlines with zero length") @@ -104,9 +107,14 @@ void run() { std::vector all_lengths; all_lengths.reserve(header_count); + // dump is declared outside the reader scope so it is available for -dump TRX field output + std::vector dump; + { Tractography::Properties properties; - Tractography::Reader reader(argument[0], properties); + auto wt_opt = get_options("tck_weights_in"); + const std::string weight_src = wt_opt.empty() ? "" : std::string(wt_opt[0][0]); + auto reader = open_tractogram(argument[0], properties, weight_src); if (properties.find("count") != properties.end()) header_count = to(properties["count"]); @@ -117,12 +125,11 @@ void run() { "widths"); } - std::vector dump; dump.reserve(header_count); ProgressBar progress("Reading track file", header_count); Streamline<> tck; - while (reader(tck)) { + while ((*reader)(tck)) { ++count; const float length = Tractography::length(tck); if (std::isfinite(length)) { @@ -144,9 +151,17 @@ void run() { ++progress; } + } + + { auto opt = get_options("dump"); - if (!opt.empty()) - File::Matrix::save_vector(dump, opt[0][0]); + if (!opt.empty()) { + const std::string dump_arg(opt[0][0]); + if (DWI::Tractography::TRX::is_trx_field_name(argument[0], dump_arg)) + DWI::Tractography::TRX::append_dps(argument[0], dump_arg, dump); + else + File::Matrix::save_vector(dump, dump_arg); + } } if (get_options("ignorezero").empty() && (empty_streamlines || zero_length_streamlines)) { @@ -249,4 +264,5 @@ void run() { } out << "\n"; } + } diff --git a/cpp/cmd/tcktransform.cpp b/cpp/cmd/tcktransform.cpp index fbc55cc7ed..e0a8309d24 100644 --- a/cpp/cmd/tcktransform.cpp +++ b/cpp/cmd/tcktransform.cpp @@ -15,8 +15,8 @@ */ #include "command.h" -#include "dwi/tractography/file.h" #include "dwi/tractography/properties.h" +#include "dwi/tractography/trx_utils.h" #include "image.h" #include "interp/linear.h" #include "ordered_thread_queue.h" @@ -24,6 +24,7 @@ using namespace MR; using namespace MR::DWI; +using namespace MR::DWI::Tractography::TRX; using namespace App; // clang-format off @@ -34,7 +35,7 @@ void usage() { SYNOPSIS = "Apply a spatial transformation to a tracks file"; ARGUMENTS - + Argument ("tracks", "the input track file.").type_tracks_in() + + Argument ("tracks", "the input track file.").type_tracks_in().type_directory_in() + Argument ("transform", "the image containing the transform.").type_image_in() + Argument ("output", "the output track file").type_tracks_out(); @@ -46,14 +47,14 @@ using TrackType = Tractography::Streamline; class Loader { public: - Loader(std::string_view file) : reader(file, properties) {} + Loader(std::string_view file) : reader(open_tractogram(file, properties)) {} - bool operator()(TrackType &item) { return reader(item); } + bool operator()(TrackType &item) { return (*reader)(item); } Tractography::Properties properties; protected: - Tractography::Reader reader; + std::unique_ptr> reader; }; class Warper { @@ -72,6 +73,7 @@ class Warper { return true; } + // Public so that run() can call it directly for the TRX in-place warp path. Eigen::Matrix pos(const Eigen::Matrix &x) { Eigen::Matrix p; if (interp.scanner(x)) { @@ -81,6 +83,8 @@ class Warper { p[1] = interp.value(); interp.index(3) = 2; p[2] = interp.value(); + } else { + p.setConstant(std::numeric_limits::quiet_NaN()); } return p; } @@ -89,31 +93,95 @@ class Warper { Interp::Linear> interp; }; +// Writer supports both TCK (Tractography::Writer) and TRX (TrxStream) output. +// For TRX output the TrxStream is finalised in the destructor. class Writer { public: Writer(std::string_view file, const Tractography::Properties &properties) : progress("applying spatial transformation to tracks", properties.find("count") == properties.end() ? 0 : to(properties.find("count")->second)), - writer(file, properties) {} + output_path(file) { + if (is_trx(file)) { + trx_stream = std::make_unique("float32"); + } else { + tck_writer = std::make_unique>(file, properties); + } + } + + ~Writer() { + if (trx_stream) { + try { + trx_stream->finalize(output_path, trx::TrxSaveOptions{}); + } catch (const std::exception &e) { + Exception(e.what()).display(); + App::exit_error_code = 1; + } + } + } bool operator()(const TrackType &item) { - writer(item); + if (trx_stream) { + std::vector> pts(item.size()); + for (size_t i = 0; i < item.size(); ++i) + pts[i] = {item[i][0], item[i][1], item[i][2]}; + trx_stream->push_streamline(pts); + } else { + (*tck_writer)(item); + } ++progress; return true; } protected: ProgressBar progress; - Tractography::Properties properties; - Tractography::Writer writer; + std::string output_path; + std::unique_ptr trx_stream; + std::unique_ptr> tck_writer; }; void run() { - Loader loader(argument[0]); - auto data = Image::open(argument[1]).with_direct_io(3); Warper warper(data); + // TRX→TRX: warp positions directly on the loaded TrxFile. All metadata + // (dps, dpv, groups, dpg) is preserved automatically because we modify the + // in-memory position array and then call save(). Vertices that fall outside + // the warp field are left at their original coordinates with a warning. + if (is_trx(argument[0]) && is_trx(argument[2])) { + auto trx = load_trx(argument[0]); + const Eigen::Index nb_s = static_cast(trx->streamlines->_offsets.size() - 1); + size_t vertex_drops = 0; + ProgressBar progress("applying spatial transformation to tracks", static_cast(nb_s)); + for (Eigen::Index s = 0; s < nb_s; ++s) { + const Eigen::Index v0 = trx->streamlines->_offsets(s, 0); + const Eigen::Index v1 = trx->streamlines->_offsets(s + 1, 0); + for (Eigen::Index v = v0; v < v1; ++v) { + const Eigen::Matrix p{ + trx->streamlines->_data(v, 0), trx->streamlines->_data(v, 1), trx->streamlines->_data(v, 2)}; + const auto wp = warper.pos(p); + if (wp.allFinite()) { + trx->streamlines->_data(v, 0) = wp[0]; + trx->streamlines->_data(v, 1) = wp[1]; + trx->streamlines->_data(v, 2) = wp[2]; + } else { + ++vertex_drops; + } + } + ++progress; + } + if (vertex_drops) + WARN(str(vertex_drops) + + " streamline vertices fell outside the warp field and were left at " + "their original coordinates; check that the warp field covers the full tractogram extent"); + trx->save(argument[2]); + return; + } + + // Stream path: handles TCK→TCK, TCK→TRX, and TRX→TCK. + // For TRX input, open_tractogram returns a TRXReader that provides geometry + // transparently. For TRX output, Writer uses TrxStream (geometry only — + // no metadata is carried over from a TCK input since there is none). + Loader loader(argument[0]); Writer writer(argument[2], loader.properties); Thread::run_ordered_queue( diff --git a/cpp/cmd/trx2connectome.cpp b/cpp/cmd/trx2connectome.cpp new file mode 100644 index 0000000000..3f7cfb22de --- /dev/null +++ b/cpp/cmd/trx2connectome.cpp @@ -0,0 +1,275 @@ +/* Copyright (c) 2008-2026 the MRtrix3 contributors. + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + * + * Covered Software is provided under this License on an "as is" + * basis, without warranty of any kind, either expressed, implied, or + * statutory, including, without limitation, warranties that the + * Covered Software is free of defects, merchantable, fit for a + * particular purpose or non-infringing. + * See the Mozilla Public License v. 2.0 for more details. + * + * For more details, see http://www.mrtrix.org/. + */ + +#include +#include +#include +#include + +#include "command.h" +#include "file/matrix.h" +#include "file/ofstream.h" +#include "progressbar.h" +#include "types.h" + +#include "connectome/connectome.h" +#include "connectome/lut.h" +#include "connectome/mat2vec.h" +#include "dwi/tractography/connectome/connectome.h" +#include "dwi/tractography/connectome/mapped_track.h" +#include "dwi/tractography/connectome/matrix.h" +#include "dwi/tractography/trx_utils.h" +#include "dwi/tractography/weights.h" + +using namespace MR; +using namespace App; +using namespace MR::DWI::Tractography; +using namespace MR::DWI::Tractography::Connectome; +using namespace MR::DWI::Tractography::TRX; + +// clang-format off +void usage() { + + AUTHOR = "MRtrix3 contributors"; + + SYNOPSIS = "Generate a connectome matrix from a TRX file whose streamlines have been assigned to groups by trxlabel"; + + DESCRIPTION + + "Reads the groups embedded in a TRX file (as created by trxlabel) and constructs a " + "symmetric connectivity matrix where each row and column corresponds to one group (node). " + "The value at position (i, j) reflects the number of streamlines whose endpoints were " + "assigned to both group i and group j." + + + "Unlike tck2connectome, no parcellation image is needed: the node assignments are already " + "encoded as groups in the TRX file. SIFT2 weights can be applied by pointing " + "-tck_weights_in at either an external text file or a dps field name in the TRX file." + + + "If all group names are integer-valued (after optional prefix stripping), those integers are used as node IDs. " + "Otherwise rows and columns follow alphabetical group order with 1-based IDs. " + "Use -out_node_names to write the ordered list of group names alongside the matrix."; + + EXAMPLES + + Example ("Default usage", + "trxlabel tracks.trx nodes.mif tracks_labeled.trx -lut FreeSurferColorLUT.txt; " + "trx2connectome tracks_labeled.trx connectome.csv -out_node_names node_names.txt", + "First label the TRX file, then build the connectome. " + "The -out_node_names file maps each row/column index to its group name.") + + + Example ("SIFT2-weighted connectome from TRX dps field", + "trx2connectome tracks_labeled.trx connectome.csv -tck_weights_in weights", + "If the TRX file contains a 'weights' dps field (e.g. added by tcksift2 -trx_dps), " + "pass the field name directly to -tck_weights_in.") + + + Example ("Group by prefix (use only Desikan-Killiany groups)", + "trx2connectome tracks_labeled.trx dk_connectome.csv -group_prefix dk", + "When a TRX file has groups from multiple atlases, -group_prefix restricts " + "the connectome to groups whose name starts with the given prefix. " + "The prefix is stripped from node names in -out_node_names."); + + ARGUMENTS + + Argument ("tracks_in", "the input TRX tractogram with groups").type_tracks_in() + + Argument ("connectome_out", "the output connectome matrix (CSV)").type_file_out(); + + OPTIONS + + Option ("group_prefix", "only include groups whose name begins with this prefix; " + "the prefix (and trailing underscore) is stripped from node names in the output") + + Argument ("prefix").type_text() + + + Option ("lut", "lookup table mapping node names to numeric indices " + "(supports FreeSurfer, AAL, ITK-SNAP, and MRtrix LUT formats); " + "when provided, the output matrix rows and columns are ordered by node index " + "rather than alphabetically, matching tck2connectome output ordering. " + "Requires -group_prefix") + + Argument ("path").type_file_in() + + + MR::DWI::Tractography::TrackWeightsInOption + + + MR::DWI::Tractography::Connectome::EdgeStatisticOption + + + MR::Connectome::MatrixOutputOptions + + + Option ("out_node_names", "write the ordered list of group names (one per line) to a text file; " + "the i-th line corresponds to column i of the connectome matrix") + + Argument ("path").type_file_out() + + + Option ("keep_unassigned", "include a row and column for streamlines not assigned to any pair of groups " + "(these appear as the first row/column in the output matrix)"); + +} +// clang-format on + +template +void execute(const node_t max_node_index, + const stat_edge statistic, + const std::vector> &streamline_groups, + const std::vector &weights, + const std::vector &group_names, + const std::string &output_path) { + const bool keep_unassigned = !get_options("keep_unassigned").empty(); + const bool symmetric = !get_options("symmetric").empty(); + const bool zero_diagonal = !get_options("zero_diagonal").empty(); + + // Matrix uses 1-indexed nodes (0 = unassigned); max valid node index = max_node_index. + Matrix connectome(max_node_index, statistic, /*vector_output=*/false, /*track_assignments=*/false); + + { + ProgressBar progress("Building connectome from TRX groups", streamline_groups.size()); + for (size_t i = 0; i < streamline_groups.size(); ++i) { + const auto &grps = streamline_groups[i]; + const float w = weights.empty() ? 1.0f : weights[i]; + // Deduplicate node memberships for this streamline. + // For a single-atlas run, a streamline has at most 2 unique nodes (one per + // endpoint), producing one edge — identical to tck2connectome behaviour. + // For a combined-atlas run, a streamline may have 4 unique nodes (2 per atlas), + // so we emit one edge per unique pair to populate all atlas-block sub-matrices. + std::vector unique_grps; + for (const auto n : grps) { + if (std::find(unique_grps.begin(), unique_grps.end(), n) == unique_grps.end()) + unique_grps.push_back(n); + } + if (unique_grps.empty()) { + // Unassigned streamline — record as (0, 0) + Mapped_track_nodepair mapped; + mapped.set_track_index(i); + mapped.set_factor(1.0f); + mapped.set_weight(w); + mapped.set_nodes(NodePair(node_t(0), node_t(0))); + connectome(mapped); + } else if (unique_grps.size() == 1) { + // One endpoint assigned — record as (node, 0) + Mapped_track_nodepair mapped; + mapped.set_track_index(i); + mapped.set_factor(1.0f); + mapped.set_weight(w); + mapped.set_nodes(NodePair(unique_grps[0], node_t(0))); + connectome(mapped); + } else { + // Two or more unique nodes: emit one edge per unique pair. + for (size_t j = 0; j < unique_grps.size(); ++j) { + for (size_t k = j + 1; k < unique_grps.size(); ++k) { + Mapped_track_nodepair mapped; + mapped.set_track_index(i); + mapped.set_factor(1.0f); + mapped.set_weight(w); + mapped.set_nodes(NodePair(unique_grps[j], unique_grps[k])); + connectome(mapped); + } + } + } + ++progress; + } + } + + connectome.finalize(); + connectome.save(output_path, keep_unassigned, symmetric, zero_diagonal); + + // Write ordered node names if requested + auto opt = get_options("out_node_names"); + if (!opt.empty()) { + const std::string names_path(opt[0][0]); + File::OFStream out{names_path}; + for (const auto &name : group_names) + out << name << "\n"; + } +} + +void run() { + const std::string tracks_path = argument[0]; + const std::string output_path = argument[1]; + + if (!is_trx(tracks_path)) + throw Exception("Input must be a TRX file; use trxlabel first to add group assignments"); + + auto trx = load_trx_header_only(tracks_path); + if (!trx) + throw Exception("Failed to load TRX file: " + tracks_path); + + if (trx->groups.empty()) + throw Exception("TRX file has no groups; run trxlabel to assign streamlines to parcellation nodes first"); + + // Determine which groups to include (optionally filtered by prefix). + std::string group_prefix; + { + auto opt = get_options("group_prefix"); + if (!opt.empty()) + group_prefix = std::string(opt[0][0]) + "_"; + } + + // Load LUT if provided (requires -group_prefix) + std::unique_ptr lut; + { + auto opt = get_options("lut"); + if (!opt.empty()) { + if (group_prefix.empty()) + throw Exception("-lut requires -group_prefix to identify which groups to match against the lookup table"); + lut = std::make_unique(std::string(opt[0][0])); + } + } + + std::vector group_names = collect_group_names(*trx, group_prefix); + + if (group_names.empty()) + throw Exception("No groups match the specified prefix '" + group_prefix + "'; check the group names with tckinfo"); + + GroupNodeMapping mapping = lut ? build_group_node_mapping(group_names, group_prefix, *lut) + : build_group_node_mapping(group_names, group_prefix); + const node_t max_node_index = static_cast(mapping.max_node_index); + + const auto streamline_groups_u32 = invert_group_memberships(*trx, mapping.group_to_node); + std::vector> streamline_groups(streamline_groups_u32.size()); + for (size_t i = 0; i < streamline_groups_u32.size(); ++i) { + streamline_groups[i].reserve(streamline_groups_u32[i].size()); + for (const auto n : streamline_groups_u32[i]) + streamline_groups[i].push_back(static_cast(n)); + } + const size_t n_streamlines = streamline_groups.size(); + + // Release TRX mmap before building connectome (reduces peak memory) + trx->close(); + trx.reset(); + + // Load per-streamline weights (from external file or TRX dps field) + std::vector weights; + { + auto opt = get_options("tck_weights_in"); + if (!opt.empty()) + weights = resolve_dps_weights(tracks_path, std::string(opt[0][0])); + if (!weights.empty() && weights.size() != n_streamlines) + throw Exception("Weight vector length (" + str(weights.size()) + ") does not match streamline count (" + + str(n_streamlines) + ")"); + } + + // Edge statistic + stat_edge statistic = stat_edge::SUM; + { + auto opt = get_options("stat_edge"); + if (!opt.empty()) + statistic = stat_edge(static_cast(opt[0][0])); + } + + // Node names for -out_node_names: index 1..N, index 0 is unassigned. + std::vector display_names; + display_names.reserve(static_cast(max_node_index)); + for (node_t n = 1; n <= max_node_index; ++n) + display_names.push_back(mapping.ordered_display_names[static_cast(n)]); + + if (max_node_index >= node_count_ram_limit) { + INFO("Very large number of nodes detected; using single-precision floating-point storage"); + execute(max_node_index, statistic, streamline_groups, weights, display_names, output_path); + } else { + execute(max_node_index, statistic, streamline_groups, weights, display_names, output_path); + } +} diff --git a/cpp/cmd/trxlabel.cpp b/cpp/cmd/trxlabel.cpp new file mode 100644 index 0000000000..4c318a9035 --- /dev/null +++ b/cpp/cmd/trxlabel.cpp @@ -0,0 +1,274 @@ +/* Copyright (c) 2008-2026 the MRtrix3 contributors. + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + * + * Covered Software is provided under this License on an "as is" + * basis, without warranty of any kind, either expressed, implied, or + * statutory, including, without limitation, warranties that the + * Covered Software is free of defects, merchantable, fit for a + * particular purpose or non-infringing. + * See the Mozilla Public License v. 2.0 for more details. + * + * For more details, see http://www.mrtrix.org/. + */ + +#include +#include +#include +#include + +#include "command.h" +#include "image.h" +#include "progressbar.h" +#include "types.h" + +#include "connectome/lut.h" +#include "dwi/tractography/connectome/connectome.h" +#include "dwi/tractography/connectome/tck2nodes.h" +#include "dwi/tractography/streamline.h" +#include "dwi/tractography/trx_utils.h" + +#include + +using namespace MR; +using namespace App; +using namespace MR::DWI::Tractography; +using namespace MR::DWI::Tractography::Connectome; +using namespace MR::DWI::Tractography::TRX; + +// clang-format off +void usage() { + + AUTHOR = "MRtrix3 contributors"; + + SYNOPSIS = "Assign streamlines in a TRX file to groups based on one or more parcellation atlases"; + + DESCRIPTION + + "Iterates over all streamlines in the input TRX file and assigns each streamline " + "to one or two groups per atlas based on the parcellation nodes touched by its endpoints, " + "using the same endpoint-to-node mechanisms as tck2connectome. " + "One group is created per node that receives at least one streamline endpoint assignment. " + "Groups are added to the output TRX file (which may be the same as the input). " + "Each group is named after the parcellation node index, or the name from the lookup " + "table if one is provided with -lut." + + + "Multiple atlases can be processed in a single invocation by specifying -nodes more than once, " + "each optionally paired with a -lut and a -prefix. " + "The number of -lut and -prefix entries must either be zero (omit entirely to use defaults) " + "or exactly equal to the number of -nodes entries, one per atlas in order. " + "All groups from all atlases are written to the output TRX in one shot." + + + MR::DWI::Tractography::Connectome::tck2nodes_description; + + EXAMPLES + + Example ("Label streamlines by Desikan-Killiany atlas parcels", + "trxlabel tracks.trx tracks_labeled.trx -nodes dk_nodes.mif -lut FreeSurferColorLUT.txt", + "Streamlines are assigned to the nearest DK parcellation node using the default " + "radial search. Group names are taken from the FreeSurfer LUT.") + + + Example ("Label with two atlases in one invocation", + "trxlabel tracks.trx tracks_labeled.trx " + "-nodes dk_nodes.mif -nodes aal_nodes.mif " + "-lut dk_lut.txt -lut aal_lut.txt " + "-prefix dk -prefix aal", + "Groups named dk_Left-Hippocampus, aal_Hippocampus_L, etc. are all written " + "to tracks_labeled.trx in a single pass over the tractogram.") + + + Example ("Label with one atlas, no LUT, with prefix", + "trxlabel tracks.trx tracks_labeled.trx -nodes schaefer200_nodes.mif -prefix sch200", + "Groups are named sch200_1, sch200_2, ... using numeric node indices."); + + ARGUMENTS + + Argument ("tracks_in", "the input TRX tractogram").type_tracks_in() + + Argument ("tracks_out", "the output TRX tractogram (can be the same as tracks_in to label in-place)") + .type_tracks_out() + .type_directory_out(); + + OPTIONS + + Option ("nodes", "parcellation image defining the nodes; can be specified multiple times for multiple atlases").allow_multiple() + + Argument ("nodes_image").type_image_in() + + + Option ("lut", "lookup table for mapping node indices to group names " + "(supports FreeSurfer, AAL, ITK-SNAP, and MRtrix LUT formats); " + "specify once per -nodes entry in the same order, or omit entirely to use numeric node indices").allow_multiple() + + Argument ("path").type_file_in() + + + Option ("prefix", "prefix to prepend to group names (followed by an underscore); " + "specify once per -nodes entry in the same order, or omit entirely for no prefix; " + "strongly recommended when using multiple atlases to avoid group name collisions").allow_multiple() + + Argument ("string").type_text() + + + MR::DWI::Tractography::Connectome::AssignmentOptions; + +} +// clang-format on + +// Sanitize a string for use as a TRX group name (which becomes a filename inside the archive). +static std::string sanitize_group_name(std::string name) { + for (char &c : name) { + if (c == ' ' || c == '/' || c == '\\' || c == ':' || c == '*' || c == '?' || c == '"' || c == '<' || c == '>') + c = '_'; + } + return name; +} + +// Populate a Streamline from TRX position arrays for streamline index i. +static void load_streamline(Streamline &tck, const trx::TrxFile &trx, const size_t i) { + tck.clear(); + tck.set_index(i); + const Eigen::Index start = trx.streamlines->_offsets(static_cast(i), 0); + const Eigen::Index end = trx.streamlines->_offsets(static_cast(i + 1), 0); + tck.reserve(static_cast(end - start)); + for (Eigen::Index j = start; j < end; ++j) + tck.push_back({trx.streamlines->_data(j, 0), trx.streamlines->_data(j, 1), trx.streamlines->_data(j, 2)}); +} + +// Assign all streamlines in trx to parcellation nodes via tck2nodes, accumulating results into +// node_to_indices (a map from node_id to a vector of streamline indices assigned to that node). +static void assign_atlas(std::map> &node_to_indices, + const trx::TrxFile &trx, + Tck2nodes_base &tck2nodes, + const size_t n_streamlines, + const std::string &atlas_label) { + ProgressBar progress("Assigning streamlines to nodes (" + atlas_label + ")", n_streamlines); + Streamline tck; + + if (tck2nodes.provides_pair()) { + for (size_t i = 0; i < n_streamlines; ++i) { + load_streamline(tck, trx, i); + const NodePair pair = tck2nodes(tck); + if (pair.first) + node_to_indices[pair.first].push_back(static_cast(i)); + if (pair.second && pair.second != pair.first) + node_to_indices[pair.second].push_back(static_cast(i)); + ++progress; + } + } else { + // -assignment_all_voxels: a streamline may touch many nodes + for (size_t i = 0; i < n_streamlines; ++i) { + load_streamline(tck, trx, i); + std::vector node_list; + tck2nodes(tck, node_list); + for (const node_t n : node_list) + if (n) + node_to_indices[n].push_back(static_cast(i)); + ++progress; + } + } +} + +void run() { + const std::string input_path = argument[0]; + const std::string output_path = argument[1]; + + // Require TRX input — groups cannot be stored in TCK files + if (!is_trx(input_path)) + throw Exception("Input tractogram must be a TRX file; " + "for TCK input, first convert with: tckconvert input.tck output.trx"); + + // Collect atlas, LUT, and prefix options + auto nodes_opts = get_options("nodes"); + auto lut_opts = get_options("lut"); + auto prefix_opts = get_options("prefix"); + + if (nodes_opts.empty()) + throw Exception("At least one -nodes option must be specified"); + + const size_t n_atlases = nodes_opts.size(); + + if (!lut_opts.empty() && lut_opts.size() != n_atlases) + throw Exception("The number of -lut entries (" + str(lut_opts.size()) + + ") must match the number of -nodes entries (" + str(n_atlases) + "), or be omitted entirely"); + if (!prefix_opts.empty() && prefix_opts.size() != n_atlases) + throw Exception("The number of -prefix entries (" + str(prefix_opts.size()) + + ") must match the number of -nodes entries (" + str(n_atlases) + "), or be omitted entirely"); + + // Load TRX once; all atlas assignments share the same positions + auto trx = load_trx(input_path); + if (!trx || !trx->streamlines) + throw Exception("Failed to load TRX file: " + input_path); + const size_t n_streamlines = trx->num_streamlines(); + + // Accumulate all groups from all atlases into one map before writing + std::map> all_groups; + + for (size_t atlas_idx = 0; atlas_idx < n_atlases; ++atlas_idx) { + // Load atlas image + const std::string nodes_path(nodes_opts[atlas_idx][0]); + auto node_header = Header::open(nodes_path); + MR::Connectome::check(node_header); + auto node_image = node_header.get_image(); + + // The assignment mechanism is shared across atlases (same search parameters) + auto tck2nodes = load_assignment_mode(node_image); + + // Load LUT for this atlas (if provided) + std::unique_ptr lut; + if (!lut_opts.empty()) + lut = std::make_unique(std::string(lut_opts[atlas_idx][0])); + + // Determine group name prefix for this atlas + std::string prefix; + if (!prefix_opts.empty()) + prefix = sanitize_group_name(std::string(prefix_opts[atlas_idx][0])) + "_"; + + // Short label for progress display + const std::string atlas_label = prefix.empty() ? Path::basename(nodes_path) : prefix.substr(0, prefix.size() - 1); + + // Assign streamlines to nodes in this atlas + std::map> node_to_indices; + assign_atlas(node_to_indices, *trx, *tck2nodes, n_streamlines, atlas_label); + + INFO(str(node_to_indices.size()) + " nodes received assignments from atlas '" + atlas_label + "'"); + + // Convert node IDs to group name strings and merge into the combined map + for (auto &[node_id, indices] : node_to_indices) { + std::string name; + if (lut) { + auto it = lut->find(node_id); + name = (it != lut->end()) ? sanitize_group_name(it->second.get_name()) : str(node_id); + } else { + name = str(node_id); + } + const std::string full_name = prefix + name; + if (all_groups.count(full_name)) + WARN("Group name '" + full_name + "' appears in multiple atlases; results will be merged"); + auto &dest = all_groups[full_name]; + dest.insert(dest.end(), indices.begin(), indices.end()); + } + + // Ensure every LUT entry produces a group (even empty ones) so that + // trx2connectome produces a matrix of the same size as tck2connectome. + if (lut) { + for (const auto &[node_id, entry] : *lut) { + const std::string name = sanitize_group_name(entry.get_name()); + const std::string full_name = prefix + name; + if (!all_groups.count(full_name)) + all_groups[full_name] = {}; // empty group — node has no assignments + } + } + } + + // Save output TRX (copy of input including any existing metadata) + if (input_path != output_path) + trx->save(output_path, ZIP_CM_STORE); + + // Release mmap before writing to the file + trx->close(); + trx.reset(); + + // Append all new groups in one shot + const std::string &target = (input_path != output_path) ? output_path : input_path; + try { + if (trx::is_trx_directory(target)) + trx::append_groups_to_directory(target, all_groups); + else + trx::append_groups_to_zip(target, all_groups); + } catch (const std::exception &e) { + throw Exception(std::string("Failed to append groups to TRX: ") + e.what()); + } + + INFO(str(all_groups.size()) + " groups written to " + target); +} diff --git a/cpp/cmd/tsfdivide.cpp b/cpp/cmd/tsfdivide.cpp index 4368e153d7..e4f670297e 100644 --- a/cpp/cmd/tsfdivide.cpp +++ b/cpp/cmd/tsfdivide.cpp @@ -18,6 +18,7 @@ #include "dwi/tractography/properties.h" #include "dwi/tractography/scalar_file.h" #include "dwi/tractography/streamline.h" +#include "dwi/tractography/trx_utils.h" using namespace MR; using namespace App; @@ -29,46 +30,118 @@ void usage() { SYNOPSIS = "Divide corresponding values in track scalar files"; + DESCRIPTION + + "Accepts .tsf track scalar files or TRX dpv fields as input and output. " + "Use -field1, -field2, and -field_out to name the dpv fields when the " + "corresponding argument is a TRX file."; + ARGUMENTS - + Argument ("input1", "the first input track scalar file.").type_file_in() - + Argument ("input2", "the second input track scalar file.").type_file_in() - + Argument ("output", "the output track scalar file").type_file_out(); + + Argument ("input1", "the first input track scalar file or TRX tractogram").type_file_in() + + Argument ("input2", "the second input track scalar file or TRX tractogram").type_file_in() + + Argument ("output", "the output track scalar file or TRX tractogram").type_file_out(); + + OPTIONS + + Option ("field1", "dpv field name to read from the first TRX input") + + Argument ("name").type_text() + + + Option ("field2", "dpv field name to read from the second TRX input") + + Argument ("name").type_text() + + + Option ("field_out", "dpv field name to write to the output TRX file " + "(output TRX must already exist; use the same path as an input to add in-place)") + + Argument ("name").type_text(); + } // clang-format on using value_type = float; +// Load all scalars from a TRX dpv field or TSF file into a vector of TrackScalars +static std::vector> load_scalars(const std::string &path, + const std::string &field) { + std::vector> out; + DWI::Tractography::TrackScalar scalar; + if (DWI::Tractography::TRX::is_trx(path)) { + DWI::Tractography::TRX::TRXScalarReader reader(path, field); + out.reserve(reader.num_streamlines()); + while (reader(scalar)) + out.push_back(scalar); + } else { + DWI::Tractography::Properties props; + DWI::Tractography::ScalarReader reader(path, props); + while (reader(scalar)) + out.push_back(scalar); + } + return out; +} + void run() { - DWI::Tractography::Properties properties1, properties2; - DWI::Tractography::ScalarReader reader1(argument[0], properties1); - DWI::Tractography::ScalarReader reader2(argument[1], properties2); - DWI::Tractography::check_properties_match(properties1, properties2, "scalar", false); - - DWI::Tractography::ScalarWriter writer(argument[2], properties1); - DWI::Tractography::TrackScalar tck_scalar1, tck_scalar2, tck_scalar_output; - while (reader1(tck_scalar1)) { - if (!reader2(tck_scalar2)) { - WARN("No more track scalars left in input file \"" + std::string(argument[1]) + "\" after " + - str(tck_scalar1.get_index() + 1) + " streamlines; " + "but more data are present in input file \"" + - std::string(argument[0]) + "\""); - break; - } - if (tck_scalar1.size() != tck_scalar2.size()) - throw Exception("track scalar length mismatch at streamline index " + str(tck_scalar1.get_index())); - - tck_scalar_output.set_index(tck_scalar1.get_index()); - tck_scalar_output.resize(tck_scalar1.size()); - for (size_t i = 0; i < tck_scalar1.size(); ++i) { - if (tck_scalar2[i] == value_type(0)) - tck_scalar_output[i] = value_type(0); - else - tck_scalar_output[i] = tck_scalar1[i] / tck_scalar2[i]; - } - writer(tck_scalar_output); + const std::string path1(argument[0]); + const std::string path2(argument[1]); + const std::string path_out(argument[2]); + + const bool trx1 = DWI::Tractography::TRX::is_trx(path1); + const bool trx2 = DWI::Tractography::TRX::is_trx(path2); + const bool trx_out = DWI::Tractography::TRX::is_trx(path_out); + + auto field1_opt = get_options("field1"); + auto field2_opt = get_options("field2"); + auto field_out_opt = get_options("field_out"); + + if (trx1 && field1_opt.empty()) + throw Exception("Use -field1 to specify the dpv field when input1 is a TRX file"); + if (trx2 && field2_opt.empty()) + throw Exception("Use -field2 to specify the dpv field when input2 is a TRX file"); + if (trx_out && field_out_opt.empty()) + throw Exception("Use -field_out to specify the dpv field when the output is a TRX file"); + if (!trx1 && !field1_opt.empty()) + WARN("-field1 is ignored for non-TRX input1"); + if (!trx2 && !field2_opt.empty()) + WARN("-field2 is ignored for non-TRX input2"); + if (!trx_out && !field_out_opt.empty()) + WARN("-field_out is ignored for non-TRX output"); + + const std::string field1 = trx1 ? std::string(field1_opt[0][0]) : ""; + const std::string field2 = trx2 ? std::string(field2_opt[0][0]) : ""; + const std::string field_out = trx_out ? std::string(field_out_opt[0][0]) : ""; + + // Load both inputs eagerly (enables in-place output on same path as input) + const auto scalars1 = load_scalars(path1, field1); + const auto scalars2 = load_scalars(path2, field2); + + if (scalars1.size() != scalars2.size()) + WARN("Input files have different streamline counts (" + str(scalars1.size()) + " vs " + str(scalars2.size()) + + "); extra streamlines in the longer file will be ignored"); + + const size_t n = std::min(scalars1.size(), scalars2.size()); + std::vector> results; + results.reserve(n); + + for (size_t i = 0; i < n; ++i) { + const auto &s1 = scalars1[i]; + const auto &s2 = scalars2[i]; + if (s1.size() != s2.size()) + throw Exception("track scalar length mismatch at streamline index " + str(s1.get_index())); + DWI::Tractography::TrackScalar out(s1.size()); + out.set_index(s1.get_index()); + for (size_t j = 0; j < s1.size(); ++j) + out[j] = (s2[j] == value_type(0)) ? value_type(0) : s1[j] / s2[j]; + results.push_back(std::move(out)); } - if (reader2(tck_scalar2)) { - WARN("No more track scalars left in input file \"" + std::string(argument[0]) + "\" after " + - str(tck_scalar1.get_index() + 1) + " streamlines; " + "but more data are present in input file \"" + - std::string(argument[1]) + "\""); + + if (trx_out) { + DWI::Tractography::TRX::TRXScalarWriter writer(path_out, field_out); + for (const auto &s : results) + writer(s); + writer.finalize(); + } else { + // Use properties from input1 if it is a TSF, otherwise empty + DWI::Tractography::Properties props; + if (!trx1) { + DWI::Tractography::ScalarReader tmp(path1, props); + } + DWI::Tractography::ScalarWriter writer(path_out, props); + for (const auto &s : results) + writer(s); } } diff --git a/cpp/cmd/tsfinfo.cpp b/cpp/cmd/tsfinfo.cpp index a9b05862dd..4b6f4ac083 100644 --- a/cpp/cmd/tsfinfo.cpp +++ b/cpp/cmd/tsfinfo.cpp @@ -18,6 +18,7 @@ #include "dwi/tractography/properties.h" #include "dwi/tractography/scalar_file.h" #include "dwi/tractography/streamline.h" +#include "dwi/tractography/trx_utils.h" #include "file/ofstream.h" #include "progressbar.h" @@ -32,12 +33,20 @@ void usage() { SYNOPSIS = "Print out information about a track scalar file"; + DESCRIPTION + + "Accepts .tsf track scalar files or TRX files (.trx). " + "For TRX files, all dpv (data-per-vertex) fields are listed. " + "Use -field to restrict output (including -ascii export) to one field."; + ARGUMENTS - + Argument ("tracks", "the input track scalar file.").allow_multiple().type_file_in(); + + Argument ("tracks", "the input track scalar file or TRX tractogram.").allow_multiple().type_file_in(); OPTIONS + Option ("count", "count number of tracks in file explicitly, ignoring the header") + + Option ("field", "for TRX input: the name of the dpv field to inspect or export with -ascii") + + Argument ("name").type_text() + + Option ("ascii", "save values of each track scalar file in individual ascii files," " with the specified prefix.") + Argument ("prefix").type_text(); @@ -47,60 +56,115 @@ void usage() { void run() { bool actual_count = !get_options("count").empty(); + auto field_opt = get_options("field"); + const std::string field_name = field_opt.empty() ? "" : std::string(field_opt[0][0]); for (size_t i = 0; i < argument.size(); ++i) { - Tractography::Properties properties; - Tractography::ScalarReader file(argument[i], properties); + const std::string path(argument[i]); std::cout << "***********************************\n"; - std::cout << " Track scalar file: \"" << argument[i] << "\"\n"; + std::cout << " Track scalar file: \"" << path << "\"\n"; + + if (DWI::Tractography::TRX::is_trx(path)) { + // TRX mode: list dpv fields + auto trx = DWI::Tractography::TRX::load_trx_header_only(path); + if (!trx) + throw Exception("Failed to load TRX file: " + path); + + std::cout << " Format: TRX\n"; + std::cout << " Streamlines: " << trx->num_streamlines() << "\n"; + std::cout << " Total vertices: " << trx->num_vertices() << "\n"; + + if (trx->data_per_vertex.empty()) { + std::cout << " No dpv fields found.\n"; + } else { + std::cout << " DPV fields (" << trx->data_per_vertex.size() << "):\n"; + for (const auto &kv : trx->data_per_vertex) { + if (!field_name.empty() && kv.first != field_name) + continue; + const size_t nv = kv.second ? static_cast(kv.second->_data.rows()) : 0; + const size_t nc = kv.second ? static_cast(kv.second->_data.cols()) : 0; + std::cout << " " << kv.first << ": " << nv << " vertices x " << nc << " col(s)\n"; + } + } - for (Tractography::Properties::iterator i = properties.begin(); i != properties.end(); ++i) { - std::string S(i->first + ':'); - S.resize(22, ' '); - std::cout << " " << S << i->second << "\n"; - } + if (actual_count) { + // For TRX the streamline count is in the header + std::cout << "actual count in file: " << trx->num_streamlines() << "\n"; + } - if (!properties.comments.empty()) { - std::cout << " Comments: "; - for (std::vector::iterator i = properties.comments.begin(); i != properties.comments.end(); ++i) - std::cout << (i == properties.comments.begin() ? "" : " ") << *i << "\n"; - } + auto opt = get_options("ascii"); + if (!opt.empty()) { + if (field_name.empty()) + throw Exception("Use -field to specify which dpv field to export to ASCII for TRX input"); + DWI::Tractography::TRX::TRXScalarReader reader(path, field_name); + DWI::Tractography::TrackScalar tck; + ProgressBar progress("writing dpv data to ascii files"); + while (reader(tck)) { + std::string filename(opt[0][0]); + filename += "-000000.txt"; + std::string num(str(tck.get_index())); + filename.replace(filename.size() - 4 - num.size(), num.size(), num); + File::OFStream out(filename); + for (float v : tck) + out << v << "\n"; + out.close(); + ++progress; + } + } - for (std::multimap::const_iterator i = properties.prior_rois.begin(); - i != properties.prior_rois.end(); - ++i) - std::cout << " ROI: " << i->first << " " << i->second << "\n"; + } else { + // TSF mode: existing behaviour + Tractography::Properties properties; + Tractography::ScalarReader file(path, properties); - if (actual_count) { - DWI::Tractography::TrackScalar<> tck; - size_t count = 0; - { - ProgressBar progress("counting tracks in file"); - while (file(tck)) { - ++count; - ++progress; + for (Tractography::Properties::iterator it = properties.begin(); it != properties.end(); ++it) { + std::string S(it->first + ':'); + S.resize(22, ' '); + std::cout << " " << S << it->second << "\n"; + } + + if (!properties.comments.empty()) { + std::cout << " Comments: "; + for (std::vector::iterator it = properties.comments.begin(); it != properties.comments.end(); ++it) + std::cout << (it == properties.comments.begin() ? "" : " ") << *it << "\n"; + } + + for (std::multimap::const_iterator it = properties.prior_rois.begin(); + it != properties.prior_rois.end(); + ++it) + std::cout << " ROI: " << it->first << " " << it->second << "\n"; + + if (actual_count) { + DWI::Tractography::TrackScalar<> tck; + size_t count = 0; + { + ProgressBar progress("counting tracks in file"); + while (file(tck)) { + ++count; + ++progress; + } } + std::cout << "actual count in file: " << count << "\n"; } - std::cout << "actual count in file: " << count << "\n"; - } - auto opt = get_options("ascii"); - if (!opt.empty()) { - ProgressBar progress("writing track scalar data to ascii files"); - DWI::Tractography::TrackScalar<> tck; - while (file(tck)) { - std::string filename(opt[0][0]); - filename += "-000000.txt"; - std::string num(str(tck.get_index())); - filename.replace(filename.size() - 4 - num.size(), num.size(), num); - - File::OFStream out(filename); - for (std::vector::iterator i = tck.begin(); i != tck.end(); ++i) - out << (*i) << "\n"; - out.close(); - - ++progress; + auto opt = get_options("ascii"); + if (!opt.empty()) { + ProgressBar progress("writing track scalar data to ascii files"); + DWI::Tractography::TrackScalar<> tck; + while (file(tck)) { + std::string filename(opt[0][0]); + filename += "-000000.txt"; + std::string num(str(tck.get_index())); + filename.replace(filename.size() - 4 - num.size(), num.size(), num); + + File::OFStream out(filename); + for (std::vector::iterator it = tck.begin(); it != tck.end(); ++it) + out << (*it) << "\n"; + out.close(); + + ++progress; + } } } } diff --git a/cpp/cmd/tsfmult.cpp b/cpp/cmd/tsfmult.cpp index 07efbf3168..3324b4a917 100644 --- a/cpp/cmd/tsfmult.cpp +++ b/cpp/cmd/tsfmult.cpp @@ -18,6 +18,7 @@ #include "dwi/tractography/properties.h" #include "dwi/tractography/scalar_file.h" #include "dwi/tractography/streamline.h" +#include "dwi/tractography/trx_utils.h" using namespace MR; using namespace App; @@ -29,44 +30,115 @@ void usage() { SYNOPSIS = "Multiply corresponding values in track scalar files"; + DESCRIPTION + + "Accepts .tsf track scalar files or TRX dpv fields as input and output. " + "Use -field1, -field2, and -field_out to name the dpv fields when the " + "corresponding argument is a TRX file."; + ARGUMENTS - + Argument ("input1", "the first input track scalar file.").type_file_in() - + Argument ("input1", "the second input track scalar file.").type_file_in() - + Argument ("output", "the output track scalar file").type_file_out(); + + Argument ("input1", "the first input track scalar file or TRX tractogram").type_file_in() + + Argument ("input2", "the second input track scalar file or TRX tractogram").type_file_in() + + Argument ("output", "the output track scalar file or TRX tractogram").type_file_out(); + + OPTIONS + + Option ("field1", "dpv field name to read from the first TRX input") + + Argument ("name").type_text() + + + Option ("field2", "dpv field name to read from the second TRX input") + + Argument ("name").type_text() + + + Option ("field_out", "dpv field name to write to the output TRX file " + "(output TRX must already exist; use the same path as an input to add in-place)") + + Argument ("name").type_text(); } // clang-format on using value_type = float; +static std::vector> load_scalars(const std::string &path, + const std::string &field) { + std::vector> out; + DWI::Tractography::TrackScalar scalar; + if (DWI::Tractography::TRX::is_trx(path)) { + DWI::Tractography::TRX::TRXScalarReader reader(path, field); + out.reserve(reader.num_streamlines()); + while (reader(scalar)) + out.push_back(scalar); + } else { + DWI::Tractography::Properties props; + DWI::Tractography::ScalarReader reader(path, props); + while (reader(scalar)) + out.push_back(scalar); + } + return out; +} + void run() { - DWI::Tractography::Properties properties1, properties2; - DWI::Tractography::ScalarReader reader1(argument[0], properties1); - DWI::Tractography::ScalarReader reader2(argument[1], properties2); - DWI::Tractography::check_properties_match(properties1, properties2, "scalar", false); - - DWI::Tractography::ScalarWriter writer(argument[2], properties1); - DWI::Tractography::TrackScalar<> tck_scalar1, tck_scalar2, tck_scalar_output; - while (reader1(tck_scalar1)) { - if (!reader2(tck_scalar2)) { - WARN("No more track scalars left in input file \"" + std::string(argument[1]) + "\" after " + - str(tck_scalar1.get_index() + 1) + " streamlines; " + "but more data are present in input file \"" + - std::string(argument[0]) + "\""); - break; - } - if (tck_scalar1.size() != tck_scalar2.size()) - throw Exception("track scalar length mismatch at streamline index " + str(tck_scalar1.get_index())); + const std::string path1(argument[0]); + const std::string path2(argument[1]); + const std::string path_out(argument[2]); - tck_scalar_output.set_index(tck_scalar1.get_index()); - tck_scalar_output.resize(tck_scalar1.size()); - for (size_t i = 0; i < tck_scalar1.size(); ++i) { - tck_scalar_output[i] = tck_scalar1[i] * tck_scalar2[i]; - } - writer(tck_scalar_output); + const bool trx1 = DWI::Tractography::TRX::is_trx(path1); + const bool trx2 = DWI::Tractography::TRX::is_trx(path2); + const bool trx_out = DWI::Tractography::TRX::is_trx(path_out); + + auto field1_opt = get_options("field1"); + auto field2_opt = get_options("field2"); + auto field_out_opt = get_options("field_out"); + + if (trx1 && field1_opt.empty()) + throw Exception("Use -field1 to specify the dpv field when input1 is a TRX file"); + if (trx2 && field2_opt.empty()) + throw Exception("Use -field2 to specify the dpv field when input2 is a TRX file"); + if (trx_out && field_out_opt.empty()) + throw Exception("Use -field_out to specify the dpv field when the output is a TRX file"); + if (!trx1 && !field1_opt.empty()) + WARN("-field1 is ignored for non-TRX input1"); + if (!trx2 && !field2_opt.empty()) + WARN("-field2 is ignored for non-TRX input2"); + if (!trx_out && !field_out_opt.empty()) + WARN("-field_out is ignored for non-TRX output"); + + const std::string field1 = trx1 ? std::string(field1_opt[0][0]) : ""; + const std::string field2 = trx2 ? std::string(field2_opt[0][0]) : ""; + const std::string field_out = trx_out ? std::string(field_out_opt[0][0]) : ""; + + const auto scalars1 = load_scalars(path1, field1); + const auto scalars2 = load_scalars(path2, field2); + + if (scalars1.size() != scalars2.size()) + WARN("Input files have different streamline counts (" + str(scalars1.size()) + " vs " + str(scalars2.size()) + + "); extra streamlines in the longer file will be ignored"); + + const size_t n = std::min(scalars1.size(), scalars2.size()); + std::vector> results; + results.reserve(n); + + for (size_t i = 0; i < n; ++i) { + const auto &s1 = scalars1[i]; + const auto &s2 = scalars2[i]; + if (s1.size() != s2.size()) + throw Exception("track scalar length mismatch at streamline index " + str(s1.get_index())); + DWI::Tractography::TrackScalar out(s1.size()); + out.set_index(s1.get_index()); + for (size_t j = 0; j < s1.size(); ++j) + out[j] = s1[j] * s2[j]; + results.push_back(std::move(out)); } - if (reader2(tck_scalar2)) { - WARN("No more track scalars left in input file \"" + std::string(argument[0]) + "\" after " + - str(tck_scalar1.get_index() + 1) + " streamlines; " + "but more data are present in input file \"" + - std::string(argument[1]) + "\""); + + if (trx_out) { + DWI::Tractography::TRX::TRXScalarWriter writer(path_out, field_out); + for (const auto &s : results) + writer(s); + writer.finalize(); + } else { + DWI::Tractography::Properties props; + if (!trx1) { + DWI::Tractography::ScalarReader tmp(path1, props); + } + DWI::Tractography::ScalarWriter writer(path_out, props); + for (const auto &s : results) + writer(s); } } diff --git a/cpp/cmd/tsfsmooth.cpp b/cpp/cmd/tsfsmooth.cpp index 942b48c16e..861526ba46 100644 --- a/cpp/cmd/tsfsmooth.cpp +++ b/cpp/cmd/tsfsmooth.cpp @@ -18,7 +18,7 @@ #include "dwi/tractography/properties.h" #include "dwi/tractography/scalar_file.h" #include "dwi/tractography/streamline.h" -#include "math/median.h" +#include "dwi/tractography/trx_utils.h" using namespace MR; using namespace App; @@ -32,55 +32,132 @@ void usage() { SYNOPSIS = "Gaussian filter a track scalar file"; + DESCRIPTION + + "Accepts .tsf track scalar files or TRX dpv fields as input and output. " + "Use -field_in / -field_out to name the dpv fields when the corresponding " + "argument is a TRX file."; + ARGUMENTS - + Argument ("input", "the input track scalar file.").type_file_in() - + Argument ("output", "the output track scalar file").type_file_out(); + + Argument ("input", "the input track scalar file or TRX tractogram").type_file_in() + + Argument ("output", "the output track scalar file or TRX tractogram").type_file_out(); OPTIONS + Option ("stdev", "apply Gaussian smoothing with the specified standard deviation." " The standard deviation is defined in units of track points" " (default: " + str(default_smoothing, 2) + ")") - + Argument ("sigma").type_float(1e-6); + + Argument ("sigma").type_float(1e-6) + + + Option ("field_in", "dpv field name to read from input TRX file") + + Argument ("name").type_text() + + + Option ("field_out", "dpv field name to write to output TRX file " + "(output TRX must already exist; use the same path as input to add in-place)") + + Argument ("name").type_text(); } // clang-format on using value_type = float; +static void smooth_scalar(DWI::Tractography::TrackScalar &out, + const DWI::Tractography::TrackScalar &in, + const std::vector &kernel, + const int floor_radius) { + out.set_index(in.get_index()); + out.resize(in.size()); + const float radius = (static_cast(kernel.size()) - 1.0F) / 2.0F; + for (int i = 0; i < static_cast(in.size()); ++i) { + float norm_factor = 0.0F; + float value = 0.0F; + for (int k = -floor_radius; k <= floor_radius; ++k) { + if (i + k >= 0 && i + k < static_cast(in.size())) { + value += kernel[static_cast(k + static_cast(radius))] * in[static_cast(i + k)]; + norm_factor += kernel[static_cast(k + static_cast(radius))]; + } + } + out[static_cast(i)] = value / norm_factor; + } +} + void run() { - DWI::Tractography::Properties properties; - DWI::Tractography::ScalarReader reader(argument[0], properties); - DWI::Tractography::ScalarWriter writer(argument[1], properties); + const std::string input_path(argument[0]); + const std::string output_path(argument[1]); + + const bool trx_in = DWI::Tractography::TRX::is_trx(input_path); + const bool trx_out = DWI::Tractography::TRX::is_trx(output_path); - float stdev = get_option_value("stdev", default_smoothing); + auto field_in_opt = get_options("field_in"); + auto field_out_opt = get_options("field_out"); - std::vector kernel(2 * ceil(2.5 * stdev) + 1, 0); - float norm_factor = 0.0; - const float radius = (kernel.size() - 1.0) / 2.0; + if (trx_in && field_in_opt.empty()) + throw Exception("Use -field_in to specify the dpv field when the input is a TRX file"); + if (trx_out && field_out_opt.empty()) + throw Exception("Use -field_out to specify the dpv field when the output is a TRX file"); + if (!trx_in && !field_in_opt.empty()) + WARN("-field_in is ignored for non-TRX input"); + if (!trx_out && !field_out_opt.empty()) + WARN("-field_out is ignored for non-TRX output"); + + const std::string field_in = trx_in ? std::string(field_in_opt[0][0]) : ""; + const std::string field_out = trx_out ? std::string(field_out_opt[0][0]) : ""; + + const float stdev = get_option_value("stdev", default_smoothing); + std::vector kernel(static_cast(2 * std::ceil(2.5F * stdev) + 1), 0.0F); + float norm_factor = 0.0F; + const float radius = (static_cast(kernel.size()) - 1.0F) / 2.0F; const int floor_radius = static_cast(std::floor(radius)); for (size_t c = 0; c < kernel.size(); ++c) { - kernel[c] = exp(-(c - radius) * (c - radius) / (2 * stdev * stdev)); + kernel[c] = std::exp(-(static_cast(c) - radius) * (static_cast(c) - radius) / (2.0F * stdev * stdev)); norm_factor += kernel[c]; } - for (size_t c = 0; c < kernel.size(); c++) + for (size_t c = 0; c < kernel.size(); ++c) kernel[c] /= norm_factor; - DWI::Tractography::TrackScalar tck_scalar; - while (reader(tck_scalar)) { - DWI::Tractography::TrackScalar tck_scalars_smoothed(tck_scalar.size()); - tck_scalars_smoothed.set_index(tck_scalar.get_index()); - - for (int i = 0; i < static_cast(tck_scalar.size()); ++i) { - float norm_factor = 0.0F; - float value = 0.0F; - for (int k = -floor_radius; k <= floor_radius; ++k) { - if (i + k >= 0 && i + k < static_cast(tck_scalar.size())) { - value += kernel[k + radius] * tck_scalar[i + k]; - norm_factor += kernel[k + radius]; - } + DWI::Tractography::TrackScalar tck_scalar, tck_smoothed; + + if (trx_in) { + std::vector> results; + { + DWI::Tractography::TRX::TRXScalarReader reader(input_path, field_in); + results.reserve(reader.num_streamlines()); + while (reader(tck_scalar)) { + smooth_scalar(tck_smoothed, tck_scalar, kernel, floor_radius); + results.push_back(tck_smoothed); + } + } // reader closed; safe to write to the same TRX + + if (trx_out) { + DWI::Tractography::TRX::TRXScalarWriter writer(output_path, field_out); + for (const auto &s : results) + writer(s); + writer.finalize(); + } else { + DWI::Tractography::Properties props; + DWI::Tractography::ScalarWriter writer(output_path, props); + for (const auto &s : results) + writer(s); + } + + } else { + DWI::Tractography::Properties properties; + DWI::Tractography::ScalarReader reader(input_path, properties); + + if (trx_out) { + std::vector> results; + while (reader(tck_scalar)) { + smooth_scalar(tck_smoothed, tck_scalar, kernel, floor_radius); + results.push_back(tck_smoothed); + } + DWI::Tractography::TRX::TRXScalarWriter writer(output_path, field_out); + for (const auto &s : results) + writer(s); + writer.finalize(); + } else { + DWI::Tractography::ScalarWriter writer(output_path, properties); + while (reader(tck_scalar)) { + smooth_scalar(tck_smoothed, tck_scalar, kernel, floor_radius); + writer(tck_smoothed); } - tck_scalars_smoothed[i] = value / norm_factor; } - writer(tck_scalars_smoothed); } } diff --git a/cpp/cmd/tsfthreshold.cpp b/cpp/cmd/tsfthreshold.cpp index da38b19aea..b52dc3949b 100644 --- a/cpp/cmd/tsfthreshold.cpp +++ b/cpp/cmd/tsfthreshold.cpp @@ -18,6 +18,7 @@ #include "dwi/tractography/properties.h" #include "dwi/tractography/scalar_file.h" #include "dwi/tractography/streamline.h" +#include "dwi/tractography/trx_utils.h" using namespace MR; using namespace App; @@ -29,13 +30,25 @@ void usage() { SYNOPSIS = "Threshold and invert track scalar files"; + DESCRIPTION + + "Accepts .tsf track scalar files or TRX dpv fields as input and output. " + "Use -field_in / -field_out to name the dpv fields when the corresponding " + "argument is a TRX file."; + ARGUMENTS - + Argument ("input", "the input track scalar file.").type_file_in() - + Argument ("T", "the desired threshold").type_float() - + Argument ("output", "the binary output track scalar file").type_file_out(); + + Argument ("input", "the input track scalar file or TRX tractogram").type_file_in() + + Argument ("T", "the desired threshold").type_float() + + Argument ("output", "the binary output track scalar file or TRX tractogram").type_file_out(); OPTIONS - + Option ("invert", "invert the output mask"); + + Option ("invert", "invert the output mask") + + + Option ("field_in", "dpv field name to read from input TRX file") + + Argument ("name").type_text() + + + Option ("field_out", "dpv field name to write to output TRX file " + "(output TRX must already exist; use the same path as input to add in-place)") + + Argument ("name").type_text(); } // clang-format on @@ -43,30 +56,87 @@ void usage() { using value_type = float; void run() { - bool invert = !get_options("invert").empty(); - float threshold = argument[1]; - - DWI::Tractography::Properties properties; - DWI::Tractography::ScalarReader reader(argument[0], properties); - DWI::Tractography::ScalarWriter writer(argument[2], properties); - - DWI::Tractography::TrackScalar tck_scalar; - while (reader(tck_scalar)) { - DWI::Tractography::TrackScalar tck_mask(tck_scalar.size()); - tck_mask.set_index(tck_scalar.get_index()); - for (size_t i = 0; i < tck_scalar.size(); ++i) { - if (invert) { - if (tck_scalar[i] > threshold) - tck_mask[i] = value_type(0); - else - tck_mask[i] = value_type(1); - } else { - if (tck_scalar[i] > threshold) - tck_mask[i] = value_type(1); - else - tck_mask[i] = value_type(0); + const std::string input_path(argument[0]); + const float threshold = argument[1]; + const std::string output_path(argument[2]); + const bool invert = !get_options("invert").empty(); + + const bool trx_in = DWI::Tractography::TRX::is_trx(input_path); + const bool trx_out = DWI::Tractography::TRX::is_trx(output_path); + + auto field_in_opt = get_options("field_in"); + auto field_out_opt = get_options("field_out"); + + if (trx_in && field_in_opt.empty()) + throw Exception("Use -field_in to specify the dpv field when the input is a TRX file"); + if (trx_out && field_out_opt.empty()) + throw Exception("Use -field_out to specify the dpv field when the output is a TRX file"); + if (!trx_in && !field_in_opt.empty()) + WARN("-field_in is ignored for non-TRX input"); + if (!trx_out && !field_out_opt.empty()) + WARN("-field_out is ignored for non-TRX output"); + + const std::string field_in = trx_in ? std::string(field_in_opt[0][0]) : ""; + const std::string field_out = trx_out ? std::string(field_out_opt[0][0]) : ""; + + DWI::Tractography::TrackScalar tck_scalar, tck_mask; + + if (trx_in) { + // Read all scalars, process them, then write output + std::vector> results; + { + DWI::Tractography::TRX::TRXScalarReader reader(input_path, field_in); + results.reserve(reader.num_streamlines()); + while (reader(tck_scalar)) { + tck_mask.set_index(tck_scalar.get_index()); + tck_mask.resize(tck_scalar.size()); + for (size_t i = 0; i < tck_scalar.size(); ++i) + tck_mask[i] = value_type(invert ? (tck_scalar[i] <= threshold) : (tck_scalar[i] > threshold)); + results.push_back(tck_mask); + } + } // reader closed here; safe to write to the same TRX + + if (trx_out) { + DWI::Tractography::TRX::TRXScalarWriter writer(output_path, field_out); + for (const auto &s : results) + writer(s); + writer.finalize(); + } else { + // TSF input mode was TRX but output is TSF — need properties from somewhere; + // use empty properties (no timestamp/count matching required for TRX→TSF). + DWI::Tractography::Properties props; + DWI::Tractography::ScalarWriter writer(output_path, props); + for (const auto &s : results) + writer(s); + } + + } else { + // TSF input mode + DWI::Tractography::Properties properties; + DWI::Tractography::ScalarReader reader(input_path, properties); + + if (trx_out) { + std::vector> results; + while (reader(tck_scalar)) { + tck_mask.set_index(tck_scalar.get_index()); + tck_mask.resize(tck_scalar.size()); + for (size_t i = 0; i < tck_scalar.size(); ++i) + tck_mask[i] = value_type(invert ? (tck_scalar[i] <= threshold) : (tck_scalar[i] > threshold)); + results.push_back(tck_mask); + } + DWI::Tractography::TRX::TRXScalarWriter writer(output_path, field_out); + for (const auto &s : results) + writer(s); + writer.finalize(); + } else { + DWI::Tractography::ScalarWriter writer(output_path, properties); + while (reader(tck_scalar)) { + tck_mask.set_index(tck_scalar.get_index()); + tck_mask.resize(tck_scalar.size()); + for (size_t i = 0; i < tck_scalar.size(); ++i) + tck_mask[i] = value_type(invert ? (tck_scalar[i] <= threshold) : (tck_scalar[i] > threshold)); + writer(tck_mask); } } - writer(tck_mask); } } diff --git a/cpp/cmd/tsfvalidate.cpp b/cpp/cmd/tsfvalidate.cpp index 57b077a727..17ca566bf0 100644 --- a/cpp/cmd/tsfvalidate.cpp +++ b/cpp/cmd/tsfvalidate.cpp @@ -19,6 +19,7 @@ #include "dwi/tractography/properties.h" #include "dwi/tractography/scalar_file.h" #include "dwi/tractography/streamline.h" +#include "dwi/tractography/trx_utils.h" #include "progressbar.h" #include "types.h" @@ -33,9 +34,18 @@ void usage() { SYNOPSIS = "Validate a track scalar file against the corresponding track data"; + DESCRIPTION + + "For TSF/TCK pairs, performs the standard header and data validation. " + "For TRX input, validates that the named dpv field has the correct number of " + "vertices (matching the TRX streamline offsets)."; + ARGUMENTS - + Argument ("tsf", "the input track scalar file").type_file_in() - + Argument ("tracks", "the track file on which the TSF is based").type_file_in(); + + Argument ("tsf", "the input track scalar file, or the TRX tractogram when using -field").type_file_in() + + Argument ("tracks", "the track file on which the TSF is based (.tck or .trx)").type_file_in(); + + OPTIONS + + Option ("field", "name of the dpv field to validate (required when the tracks argument is a TRX file)") + + Argument ("name").type_text(); } // clang-format on @@ -43,9 +53,66 @@ void usage() { typedef float value_type; void run() { + const std::string tsf_path(argument[0]); + const std::string tck_path(argument[1]); + + auto field_opt = get_options("field"); + + // TRX mode: validate that the named dpv field vertex count matches num_vertices() + if (TRX::is_trx(tck_path)) { + if (field_opt.empty()) + throw Exception("Use -field to specify the dpv field to validate when the tracks argument is a TRX file"); + + const std::string field_name(field_opt[0][0]); + auto trx = TRX::load_trx_header_only(tck_path); + if (!trx) + throw Exception("Failed to load TRX file: " + tck_path); + + const size_t n_vertices = trx->num_vertices(); + const size_t n_streamlines = trx->num_streamlines(); + + auto it = trx->data_per_vertex.find(field_name); + if (it == trx->data_per_vertex.end() || !it->second) + throw Exception("TRX file has no dpv field '" + field_name + "'"); + + const size_t dpv_rows = static_cast(it->second->_data.rows()); + + size_t error_count = 0; + if (dpv_rows != n_vertices) { + CONSOLE("dpv field '" + field_name + "' has " + str(dpv_rows) + " rows but TRX has " + str(n_vertices) + + " vertices"); + ++error_count; + } + + // Also check that per-streamline vertex counts sum to total vertices + // (validates that offsets are self-consistent). + size_t offset_sum = 0; + for (size_t i = 0; i < n_streamlines; ++i) { + const size_t start = static_cast(trx->streamlines->_offsets(static_cast(i), 0)); + const size_t end = static_cast(trx->streamlines->_offsets(static_cast(i + 1), 0)); + offset_sum += end - start; + } + if (offset_sum != n_vertices) { + CONSOLE("TRX streamline offsets sum to " + str(offset_sum) + " vertices but num_vertices() reports " + + str(n_vertices)); + ++error_count; + } + + if (error_count) + throw Exception("Error" + std::string(error_count > 1 ? "s" : "") + " detected"); + else + CONSOLE("TRX dpv field '" + field_name + "' checked OK (" + str(dpv_rows) + " vertices, " + str(n_streamlines) + + " streamlines)"); + return; + } + + // TSF/TCK mode: existing validation logic + if (!field_opt.empty()) + WARN("-field is ignored when the tracks argument is a TCK file"); + Properties tsf_properties, tck_properties; - ScalarReader tsf_reader(argument[0], tsf_properties); - Reader tck_reader(argument[1], tck_properties); + ScalarReader tsf_reader(tsf_path, tsf_properties); + Reader tck_reader(tck_path, tck_properties); size_t error_count = 0; Properties::const_iterator tsf_count_field = tsf_properties.find("count"); diff --git a/cpp/core/CMakeLists.txt b/cpp/core/CMakeLists.txt index 28a6951372..137c236960 100644 --- a/cpp/core/CMakeLists.txt +++ b/cpp/core/CMakeLists.txt @@ -119,6 +119,7 @@ target_link_libraries(mrtrix-core PUBLIC nlohmann_json::nlohmann_json nifti::nifti tcb::span + trx-cpp::trx ) if(MRTRIX_ENABLE_GPU) diff --git a/cpp/core/app.cpp b/cpp/core/app.cpp index 1d320e3b08..974f437a38 100644 --- a/cpp/core/app.cpp +++ b/cpp/core/app.cpp @@ -1078,7 +1078,8 @@ void parse() { ArgTypeFlags types_not_input_tractogram(i.arg->types); types_not_input_tractogram.reset(ArgTypeFlags::TracksIn); if (!types_not_input_tractogram.any()) { - if (!Path::has_suffix(text, ".tck")) + if (!Path::has_suffix(text, ".tck") && !Path::has_suffix(text, ".trx") && + !(i.arg->types[ArgTypeFlags::DirectoryIn] && Path::is_dir(text))) throw Exception("input file \"" + text + "\" is not a valid track file"); } } @@ -1086,8 +1087,9 @@ void parse() { ArgTypeFlags types_not_output_tractogram(i.arg->types); types_not_output_tractogram.reset(ArgTypeFlags::TracksOut); if (!types_not_output_tractogram.any()) { - if (!Path::has_suffix(text, ".tck")) - throw Exception("output track file \"" + text + "\" must use the .tck suffix"); + if (!Path::has_suffix(text, ".tck") && !Path::has_suffix(text, ".trx") && + !i.arg->types[ArgTypeFlags::DirectoryOut]) + throw Exception("output track file \"" + text + "\" must use the .tck or .trx suffix"); } } } @@ -1141,7 +1143,8 @@ void parse() { ArgTypeFlags types_not_input_tractogram(arg.types); types_not_input_tractogram.reset(ArgTypeFlags::TracksIn); if (!types_not_input_tractogram.any()) { - if (!Path::has_suffix(text, ".tck")) + if (!Path::has_suffix(text, ".tck") && !Path::has_suffix(text, ".trx") && + !(arg.types[ArgTypeFlags::DirectoryIn] && Path::is_dir(text))) throw Exception("input file \"" + text + "\"" + " for option \"-" + std::string(i.opt->id) + "\"" + " is not a valid track file"); } @@ -1150,9 +1153,10 @@ void parse() { ArgTypeFlags types_not_output_tractogram(arg.types); types_not_output_tractogram.reset(ArgTypeFlags::TracksOut); if (!types_not_output_tractogram.any()) { - if (!Path::has_suffix(text, ".tck")) + if (!Path::has_suffix(text, ".tck") && !Path::has_suffix(text, ".trx") && + !arg.types[ArgTypeFlags::DirectoryOut]) throw Exception("output track file \"" + text + "\"" + " for option \"-" + std::string(i.opt->id) + "\"" + - " must use the .tck suffix"); + " must use the .tck or .trx suffix"); } } } diff --git a/cpp/core/dwi/tractography/SIFT/model.h b/cpp/core/dwi/tractography/SIFT/model.h index c4910c8506..f515039fb3 100644 --- a/cpp/core/dwi/tractography/SIFT/model.h +++ b/cpp/core/dwi/tractography/SIFT/model.h @@ -37,6 +37,7 @@ #include "dwi/tractography/SIFT/track_contribution.h" #include "dwi/tractography/SIFT/track_index_range.h" #include "dwi/tractography/SIFT/types.h" +#include "dwi/tractography/trx_utils.h" namespace MR::DWI::Tractography::SIFT { @@ -134,6 +135,21 @@ template Model::~Model() { } template void Model::map_streamlines(std::string_view path) { + if (TRX::is_trx(path)) { + TRX::TRXReader reader(path); + const track_t count = static_cast(reader.num_streamlines()); + if (!count) + throw Exception("Cannot map streamlines: TRX file " + Path::basename(path) + " is empty"); + contributions.assign(count, nullptr); + Tractography::Properties properties; + reader.populate_properties(properties); + TrackMappingWorker worker(*this, Mapping::determine_upsample_ratio(Fixel_map::header(), properties, 0.1f)); + Thread::run_queue(reader, Thread::batch(Tractography::Streamline<>()), Thread::multi(worker)); + tck_file_path = path; + INFO("Proportionality coefficient after streamline mapping is " + str(mu())); + return; + } + Tractography::Properties properties; Tractography::Reader<> file(path, properties); diff --git a/cpp/core/dwi/tractography/SIFT/model_base.h b/cpp/core/dwi/tractography/SIFT/model_base.h index 6ffb219373..367df639af 100644 --- a/cpp/core/dwi/tractography/SIFT/model_base.h +++ b/cpp/core/dwi/tractography/SIFT/model_base.h @@ -30,6 +30,7 @@ #include "dwi/directions/set.h" #include "dwi/tractography/file.h" +#include "dwi/tractography/trx_utils.h" #include "dwi/tractography/ACT/tissues.h" @@ -183,13 +184,13 @@ template void ModelBase::scale_FDs_by_GM() { template void ModelBase::map_streamlines(std::string_view path) { Tractography::Properties properties; - Tractography::Reader<> file(path, properties); + auto reader = TRX::open_tractogram(path, properties); const track_t count = (properties.find("count") == properties.end()) ? 0 : to(properties["count"]); if (!count) throw Exception("Cannot map streamlines: track file " + Path::basename(path) + " is empty"); - Mapping::TrackLoader loader(file, count); + Mapping::TrackLoader loader(*reader, count); Mapping::TrackMapperBase mapper(Fixel_map::header(), dirs); mapper.set_upsample_ratio(Mapping::determine_upsample_ratio(Fixel_map::header(), properties, 0.1)); mapper.set_use_precise_mapping(true); diff --git a/cpp/core/dwi/tractography/SIFT/sifter.cpp b/cpp/core/dwi/tractography/SIFT/sifter.cpp index fa9317bf5d..73ccbf26dc 100644 --- a/cpp/core/dwi/tractography/SIFT/sifter.cpp +++ b/cpp/core/dwi/tractography/SIFT/sifter.cpp @@ -15,6 +15,7 @@ */ #include "dwi/tractography/SIFT/sifter.h" +#include "dwi/tractography/trx_utils.h" #include "memory.h" #include "progressbar.h" @@ -345,6 +346,23 @@ void SIFTer::perform_filtering() { } void SIFTer::output_filtered_tracks(std::string_view input_path, std::string_view output_path) const { + if (TRX::is_trx(input_path)) { + Tractography::Properties p; + p["SIFT_mu"] = str(mu()); + Tractography::Writer writer(output_path, p); + TRX::TRXReader reader(input_path); + track_t tck_counter = 0; + Tractography::Streamline tck; + ProgressBar progress("Writing filtered tracks output file", contributions.size()); + while (reader(tck) && tck_counter < contributions.size()) { + if (contributions[tck_counter++]) + writer(tck); + else + writer.skip(); + ++progress; + } + return; + } Tractography::Properties p; Tractography::Reader reader(input_path, p); p["SIFT_mu"] = str(mu()); diff --git a/cpp/core/dwi/tractography/SIFT/sifter.h b/cpp/core/dwi/tractography/SIFT/sifter.h index ca6e7dec33..ec42bebbec 100644 --- a/cpp/core/dwi/tractography/SIFT/sifter.h +++ b/cpp/core/dwi/tractography/SIFT/sifter.h @@ -51,6 +51,13 @@ class SIFTer : public Model { void perform_filtering(); void output_filtered_tracks(std::string_view, std::string_view) const; void output_selection(std::string_view) const; + std::vector get_selected_indices() const { + std::vector indices; + for (track_t i = 0; i != contributions.size(); ++i) + if (contributions[i]) + indices.push_back(static_cast(i)); + return indices; + } // CONFIGURATION OPTIONS void set_term_number(const track_t i) { term_number = i; } diff --git a/cpp/core/dwi/tractography/SIFT2/tckfactor.cpp b/cpp/core/dwi/tractography/SIFT2/tckfactor.cpp index 2a6a15e7e8..0824a2b9cc 100644 --- a/cpp/core/dwi/tractography/SIFT2/tckfactor.cpp +++ b/cpp/core/dwi/tractography/SIFT2/tckfactor.cpp @@ -362,6 +362,16 @@ void TckFactor::output_factors(std::string_view path) const { File::Matrix::save_vector(weights, path); } +std::vector TckFactor::get_factors() const { + if (static_cast(coefficients.size()) != contributions.size()) + throw Exception("Cannot get weighting factors if they have not first been estimated!"); + std::vector weights(num_tracks()); + for (SIFT::track_t i = 0; i != num_tracks(); ++i) + weights[i] = static_cast( + (coefficients[i] == min_coeff || !std::isfinite(coefficients[i])) ? 0.0 : std::exp(coefficients[i])); + return weights; +} + void TckFactor::output_coefficients(std::string_view path) const { File::Matrix::save_vector(coefficients, path); } void TckFactor::output_TD_images(std::string_view dirpath, diff --git a/cpp/core/dwi/tractography/SIFT2/tckfactor.h b/cpp/core/dwi/tractography/SIFT2/tckfactor.h index a42aceba7d..06720a03e5 100644 --- a/cpp/core/dwi/tractography/SIFT2/tckfactor.h +++ b/cpp/core/dwi/tractography/SIFT2/tckfactor.h @@ -87,6 +87,7 @@ class TckFactor : public SIFT::Model { void report_entropy() const; void output_factors(std::string_view) const; + std::vector get_factors() const; void output_coefficients(std::string_view) const; void output_TD_images(std::string_view, std::string_view, std::string_view) const; diff --git a/cpp/core/dwi/tractography/mapping/loader.h b/cpp/core/dwi/tractography/mapping/loader.h index 5c92102140..3e8d494b72 100644 --- a/cpp/core/dwi/tractography/mapping/loader.h +++ b/cpp/core/dwi/tractography/mapping/loader.h @@ -27,7 +27,7 @@ namespace MR::DWI::Tractography::Mapping { class TrackLoader { public: - TrackLoader(Reader<> &file, const size_t to_load = 0, std::string_view msg = "mapping tracks to image") + TrackLoader(ReaderInterface &file, const size_t to_load = 0, std::string_view msg = "mapping tracks to image") : reader(file), tracks_to_load(to_load), progress(!msg.empty() ? new ProgressBar(msg, tracks_to_load) : nullptr) {} @@ -49,7 +49,7 @@ class TrackLoader { } protected: - Reader<> &reader; + ReaderInterface &reader; const size_t tracks_to_load; std::unique_ptr progress; }; diff --git a/cpp/core/dwi/tractography/mapping/mapping.cpp b/cpp/core/dwi/tractography/mapping/mapping.cpp index b5175eda5c..de84395169 100644 --- a/cpp/core/dwi/tractography/mapping/mapping.cpp +++ b/cpp/core/dwi/tractography/mapping/mapping.cpp @@ -15,9 +15,14 @@ */ #include "dwi/tractography/mapping/mapping.h" +#include "dwi/tractography/trx_utils.h" namespace MR::DWI::Tractography::Mapping { +// Bring open_tractogram into scope so both string-path overloads below can use +// it without repeating the full namespace path. +using MR::DWI::Tractography::TRX::open_tractogram; + size_t determine_upsample_ratio(const Header &header, const float step_size, const float ratio) { size_t upsample_ratio = 1; if (step_size && std::isfinite(step_size)) @@ -28,7 +33,7 @@ size_t determine_upsample_ratio(const Header &header, const float step_size, con size_t determine_upsample_ratio(const Header &header, std::string_view tck_path, const float ratio) { Properties properties; - Reader<> reader(tck_path, properties); + auto reader = open_tractogram(tck_path, properties); return determine_upsample_ratio(header, properties, ratio); } @@ -38,10 +43,7 @@ size_t determine_upsample_ratio(const Header &header, const Tractography::Proper return determine_upsample_ratio(header, properties.get_stepsize(), ratio); } -void generate_header(Header &header, std::string_view tck_file_path, const std::vector &voxel_size) { - - Properties properties; - Reader<> file(tck_file_path, properties); +void generate_header(Header &header, ReaderInterface &reader, const std::vector &voxel_size) { Streamline<> tck; size_t track_counter = 0; @@ -51,7 +53,7 @@ void generate_header(Header &header, std::string_view tck_file_path, const std:: { ProgressBar progress("creating new template image", 0); - while (file(tck) && track_counter++ < streamlines_for_bounding_box) { + while (reader(tck) && track_counter++ < streamlines_for_bounding_box) { for (const auto &i : tck) { min_values[0] = std::min(min_values[0], i[0]); max_values[0] = std::max(max_values[0], i[0]); @@ -78,7 +80,14 @@ void generate_header(Header &header, std::string_view tck_file_path, const std:: header.transform().matrix().setIdentity(); header.transform().translation() = min_values.cast(); - file.close(); +} + +void generate_header(Header &header, std::string_view tck_file_path, const std::vector &voxel_size) { + Properties properties; + // open_tractogram handles both TCK and TRX; the reader is local so the caller's + // reader object is never consumed (no re-open needed in the calling command). + auto reader = open_tractogram(tck_file_path, properties); + generate_header(header, *reader, voxel_size); } void oversample_header(Header &header, const std::vector &voxel_size) { diff --git a/cpp/core/dwi/tractography/mapping/mapping.h b/cpp/core/dwi/tractography/mapping/mapping.h index adeeb79443..164bc673d2 100644 --- a/cpp/core/dwi/tractography/mapping/mapping.h +++ b/cpp/core/dwi/tractography/mapping/mapping.h @@ -43,6 +43,7 @@ size_t determine_upsample_ratio(const Header &, std::string_view, const float); size_t determine_upsample_ratio(const Header &, const Tractography::Properties &, const float); void generate_header(Header &, std::string_view, const std::vector &); +void generate_header(Header &, ReaderInterface &, const std::vector &); void oversample_header(Header &, const std::vector &); diff --git a/cpp/core/dwi/tractography/seeding/dynamic.cpp b/cpp/core/dwi/tractography/seeding/dynamic.cpp index 522f05e1e7..cf94ba6682 100644 --- a/cpp/core/dwi/tractography/seeding/dynamic.cpp +++ b/cpp/core/dwi/tractography/seeding/dynamic.cpp @@ -319,7 +319,7 @@ void Dynamic::perform_fixel_masking() { } bool WriteKernelDynamic::operator()(const Tracking::GeneratedTrack &in, Tractography::Streamline<> &out) { - out.set_index(writer.count); + out.set_index(writer->count); out.weight = 1.0F; if (!WriteKernel::operator()(in)) { out.clear(); diff --git a/cpp/core/dwi/tractography/tracking/write_kernel.cpp b/cpp/core/dwi/tractography/tracking/write_kernel.cpp index 682ade890d..6263aa0eec 100644 --- a/cpp/core/dwi/tractography/tracking/write_kernel.cpp +++ b/cpp/core/dwi/tractography/tracking/write_kernel.cpp @@ -15,15 +15,93 @@ */ #include "dwi/tractography/tracking/write_kernel.h" +#include "dwi/tractography/trx_utils.h" namespace MR::DWI::Tractography::Tracking { +namespace { + +// Wraps Writer (TCK) behind AbstractTrackWriter. +// Writer manages its own internal count/total_count for the file header; +// we maintain the base-class fields independently for WriteKernel's use. +class TCKWriterAdapter : public AbstractTrackWriter { +public: + TCKWriterAdapter(std::string_view path, const Tractography::Properties &props) : impl(path, props) {} + bool operator()(const Streamline &tck) override { + impl(tck); + ++count; + ++total_count; + return true; + } + void skip() override { + impl.skip(); + ++total_count; + } + +private: + Tractography::Writer impl; +}; + +// Wraps TrxStream behind AbstractTrackWriter for TRX output without pre-counting. +class TRXWriterAdapter : public AbstractTrackWriter { +public: + explicit TRXWriterAdapter(std::string_view path, + std::string_view positions_dtype = "float32", + const Tractography::Properties *props = nullptr) + : output_path(std::string(path)), stream(std::string(positions_dtype)) { + if (props) { + json::object meta; + for (const auto &[key, val] : *props) { + if (key == "trx_positions_dtype") + continue; // internal implementation detail, not useful metadata + meta[key] = json(val); + } + if (!meta.empty()) + stream.header = trx::_json_set(stream.header, "metadata", json(meta)); + } + } + bool operator()(const Streamline &tck) override { + std::vector> pts(tck.size()); + for (size_t i = 0; i < tck.size(); ++i) + pts[i] = {tck[i][0], tck[i][1], tck[i][2]}; + stream.push_streamline(pts); + ++count; + ++total_count; + return true; + } + void skip() override { ++total_count; } + ~TRXWriterAdapter() override { + try { + stream.finalize(output_path, trx::TrxSaveOptions{}); + } catch (const std::exception &e) { + Exception(e.what()).display(); + App::exit_error_code = 1; + } + } + +private: + std::string output_path; + trx::TrxStream stream; +}; + +} // anonymous namespace + +std::unique_ptr WriteKernel::create_writer(std::string_view path, + const Tractography::Properties &properties) { + if (TRX::is_trx(path)) { + const auto it = properties.find("trx_positions_dtype"); + const std::string dtype = (it != properties.end() && !it->second.empty()) ? it->second : "float32"; + return std::make_unique(path, dtype, &properties); + } + return std::make_unique(path, properties); +} + bool WriteKernel::operator()(const GeneratedTrack &tck) { if (complete()) return false; if (!tck.empty() && output_seeds) { const auto &p = tck[tck.get_seed_index()]; - (*output_seeds) << str(writer.count) << "," << str(tck.get_seed_index()) << "," << str(p[0]) << "," << str(p[1]) + (*output_seeds) << str(writer->count) << "," << str(tck.get_seed_index()) << "," << str(p[0]) << "," << str(p[1]) << "," << str(p[2]) << ",\n"; } switch (tck.get_status()) { @@ -34,12 +112,12 @@ bool WriteKernel::operator()(const GeneratedTrack &tck) { ++selected; ++streamlines; ++seeds; - writer(tck); + (*writer)(tck); break; case GeneratedTrack::status_t::TRACK_REJECTED: ++streamlines; ++seeds; - writer.skip(); + writer->skip(); break; case GeneratedTrack::status_t::SEED_REJECTED: ++seeds; diff --git a/cpp/core/dwi/tractography/tracking/write_kernel.h b/cpp/core/dwi/tractography/tracking/write_kernel.h index c5b3a9d83e..9bc347f56b 100644 --- a/cpp/core/dwi/tractography/tracking/write_kernel.h +++ b/cpp/core/dwi/tractography/tracking/write_kernel.h @@ -35,11 +35,22 @@ namespace MR::DWI::Tractography::Tracking { +// Abstract base for the format-specific track writer held by WriteKernel. +// Concrete implementations (TCKWriterAdapter, TRXWriterAdapter) live in write_kernel.cpp +// so that TRX headers do not propagate to all consumers of this header. +struct AbstractTrackWriter { + uint64_t count = 0; + uint64_t total_count = 0; + virtual bool operator()(const Streamline &tck) = 0; + virtual void skip() = 0; + virtual ~AbstractTrackWriter() = default; +}; + class WriteKernel { public: WriteKernel(const SharedBase &shared, std::string_view output_file, const DWI::Tractography::Properties &properties) : S(shared), - writer(output_file, properties), + writer(create_writer(output_file, properties)), always_increment(S.properties.seeds.is_finite() || !S.max_num_tracks), warn_on_max_seeds(S.implicit_max_num_seeds), seeds(0), @@ -63,8 +74,8 @@ class WriteKernel { // Use set_text() rather than update() here to force update of the text before progress goes out of scope progress.set_text( printf("%8" PRIu64 " seeds, %8" PRIu64 " streamlines, %8" PRIu64 " selected", seeds, streamlines, selected)); - if (warn_on_max_seeds && writer.total_count == S.max_num_seeds && S.max_num_tracks && - writer.count < S.max_num_tracks) { + if (warn_on_max_seeds && writer->total_count == S.max_num_seeds && S.max_num_tracks && + writer->count < S.max_num_tracks) { WARN("less than desired streamline number due to implicit maximum number of seeds; set -seeds 0 to override"); } if (output_seeds) { @@ -97,9 +108,13 @@ class WriteKernel { return ((S.max_num_tracks && selected >= S.max_num_tracks) || (S.max_num_seeds && seeds >= S.max_num_seeds)); } +private: + static std::unique_ptr create_writer(std::string_view path, + const DWI::Tractography::Properties &properties); + protected: const SharedBase &S; - Writer<> writer; + std::unique_ptr writer; const bool always_increment, warn_on_max_seeds; size_t seeds, streamlines, selected; std::unique_ptr output_seeds; diff --git a/cpp/core/dwi/tractography/trx_utils.h b/cpp/core/dwi/tractography/trx_utils.h new file mode 100644 index 0000000000..e9866c743b --- /dev/null +++ b/cpp/core/dwi/tractography/trx_utils.h @@ -0,0 +1,922 @@ +/* Copyright (c) 2008-2026 the MRtrix3 contributors. + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + * + * Covered Software is provided under this License on an "as is" + * basis, without warranty of any kind, either expressed, implied, or + * statutory, including, without limitation, warranties that the + * Covered Software is free of defects, merchantable, fit for a + * particular purpose or non-infringing. + * See the Mozilla Public License v. 2.0 for more details. + * + * For more details, see http://www.mrtrix.org/. + */ + +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "app.h" +#include "dwi/tractography/file.h" +#include "dwi/tractography/properties.h" +#include "dwi/tractography/streamline.h" +#include "exception.h" +#include "file/matrix.h" +#include "file/path.h" +#include + +namespace MR::DWI::Tractography::TRX { + +// Detect whether a path is a TRX file (zip archive or uncompressed directory) +inline bool is_trx(std::string_view path) { + if (Path::has_suffix(path, ".trx")) + return true; + try { + return trx::is_trx_directory(std::string(path)); + } catch (const std::exception &) { + return false; + } +} + +// Returns true when output_arg should be treated as a TRX embedded field name +// rather than a file path. Conditions: the input tractogram is a TRX file and +// the output argument contains no '.' (no extension) and no path separators. +// This drives the "no extension → embed" CLI convention used by tcksift2, +// tcksample, and fixel2tsf. +inline bool is_trx_field_name(std::string_view input_path, std::string_view output_arg) { + if (!is_trx(input_path)) + return false; + return output_arg.find('.') == std::string_view::npos && output_arg.find('/') == std::string_view::npos && + output_arg.find('\\') == std::string_view::npos; +} + +// Load a TRX file with positions guaranteed as float32. +// For float16/float64 files, delegates to trx::load_float32_positions() which +// performs chunked in-place conversion into owned storage — no segfault, no +// double-size allocation. +// Use this whenever streamline coordinates will be read or written. +inline std::unique_ptr> load_trx(std::string_view path) { + const std::string filename(path); + const auto dtype = trx::detect_positions_scalar_type(filename, trx::TrxScalarType::Float32); + if (dtype == trx::TrxScalarType::Float16) + WARN("TRX file '" + filename + "' has float16 positions; converting to float32 on load"); + else if (dtype == trx::TrxScalarType::Float64) + WARN("TRX file '" + filename + "' has float64 positions; converting to float32 (precision loss)"); + return trx::load_float32_positions(filename); +} + +// Load a TRX file for metadata inspection only (groups, dps, dpv field +// names/shapes, streamline/vertex counts). Positions are mmapped with their +// native dtype but never read, so this is safe for float16/float64 files and +// avoids any conversion overhead. +// Do NOT read streamline coordinates from the returned TrxFile; use load_trx(). +inline std::unique_ptr> load_trx_header_only(std::string_view path) { + return trx::load(std::string(path)); +} + +// Count streamlines and total vertices in a TRX file +inline std::pair count_trx(std::string_view path) { + auto trx = load_trx_header_only(path); + if (!trx) + throw Exception("Failed to load TRX file: " + std::string(path)); + return {trx->num_streamlines(), trx->num_vertices()}; +} + +struct GroupNodeMapping { + std::map group_to_node; + std::vector ordered_display_names; // 1-based node display names, index 0 unused + uint32_t max_node_index = 0; + bool integer_names = false; +}; + +inline bool parse_group_node_id(const std::string &group_name, const std::string &prefix, uint32_t &out_node) { + const std::string stripped = prefix.empty() ? group_name : group_name.substr(prefix.size()); + try { + const uint32_t parsed = to(stripped); + if (parsed <= 0) + return false; + out_node = parsed; + return true; + } catch (...) { + return false; + } +} + +inline GroupNodeMapping build_group_node_mapping(const std::vector &group_names, + const std::string &prefix) { + GroupNodeMapping mapping; + if (group_names.empty()) + return mapping; + + bool all_integer = true; + std::map reverse; + for (const auto &name : group_names) { + uint32_t parsed = 0; + if (!parse_group_node_id(name, prefix, parsed)) { + all_integer = false; + break; + } + if (reverse.count(parsed)) { + all_integer = false; + break; + } + reverse.emplace(parsed, prefix.empty() ? name : name.substr(prefix.size())); + } + + if (all_integer) { + mapping.integer_names = true; + mapping.max_node_index = reverse.rbegin()->first; + mapping.ordered_display_names.assign(static_cast(mapping.max_node_index) + 1, ""); + for (const auto &[node, display] : reverse) + mapping.ordered_display_names[static_cast(node)] = display; + for (const auto &name : group_names) { + uint32_t parsed = 0; + parse_group_node_id(name, prefix, parsed); + mapping.group_to_node[name] = parsed; + } + return mapping; + } + + mapping.integer_names = false; + mapping.max_node_index = static_cast(group_names.size()); + mapping.ordered_display_names.assign(static_cast(mapping.max_node_index) + 1, ""); + for (size_t i = 0; i < group_names.size(); ++i) { + const uint32_t node = static_cast(i + 1); + mapping.group_to_node[group_names[i]] = node; + mapping.ordered_display_names[static_cast(node)] = + prefix.empty() ? group_names[i] : group_names[i].substr(prefix.size()); + } + return mapping; +} + +// Overload that uses a LUT to recover numeric node-ID ordering. +// The LUT maps node_id → LUT_node; we invert to name → node_id and match +// stripped group names against the LUT names. This produces a matrix with +// rows/columns ordered by node index, matching tck2connectome output. +template +inline GroupNodeMapping +build_group_node_mapping(const std::vector &group_names, const std::string &prefix, const LUT_type &lut) { + GroupNodeMapping mapping; + if (group_names.empty()) + return mapping; + + // Build reverse map: sanitized LUT name → node_id + // (sanitize the same way trxlabel does — spaces and special chars become '_') + auto sanitize = [](std::string name) { + for (char &c : name) { + if (c == ' ' || c == '/' || c == '\\' || c == ':' || c == '*' || c == '?' || c == '"' || c == '<' || c == '>') + c = '_'; + } + return name; + }; + + std::map name_to_node; + uint32_t max_id = 0; + for (const auto &[node_id, entry] : lut) { + const std::string sanitized = sanitize(entry.get_name()); + name_to_node[sanitized] = static_cast(node_id); + if (static_cast(node_id) > max_id) + max_id = static_cast(node_id); + } + + mapping.integer_names = true; + mapping.max_node_index = max_id; + mapping.ordered_display_names.assign(static_cast(max_id) + 1, ""); + + // Populate display names from LUT (all entries, even those with no groups) + for (const auto &[node_id, entry] : lut) { + const std::string sanitized = sanitize(entry.get_name()); + mapping.ordered_display_names[static_cast(node_id)] = sanitized; + } + + // Map each group to its LUT node ID + for (const auto &name : group_names) { + const std::string stripped = prefix.empty() ? name : name.substr(prefix.size()); + auto it = name_to_node.find(stripped); + if (it != name_to_node.end()) { + mapping.group_to_node[name] = it->second; + } else { + WARN("Group '" + name + "' (stripped: '" + stripped + "') not found in LUT; skipping"); + } + } + + return mapping; +} + +inline std::vector collect_group_names(const trx::TrxFile &trx, const std::string &prefix) { + std::vector names; + for (const auto &[name, _] : trx.groups) { + if (prefix.empty() || name.substr(0, prefix.size()) == prefix) + names.push_back(name); + } + return names; +} + +inline std::vector> +invert_group_memberships(const trx::TrxFile &trx, const std::map &group_to_node) { + const size_t n_streamlines = trx.num_streamlines(); + std::vector> memberships(n_streamlines); + for (const auto &[name, _] : trx.groups) { + auto it = group_to_node.find(name); + if (it == group_to_node.end()) + continue; + const uint32_t node_id = it->second; + const auto *members = trx.get_group_members(name); + if (!members) + continue; + for (Eigen::Index r = 0; r < members->_matrix.rows(); ++r) { + const auto idx = static_cast(members->_matrix(r, 0)); + if (idx < n_streamlines) + memberships[idx].push_back(node_id); + } + } + for (auto &nodes : memberships) { + std::sort(nodes.begin(), nodes.end()); + nodes.erase(std::unique(nodes.begin(), nodes.end()), nodes.end()); + } + return memberships; +} + +// Construct a TypedArray from a std::vector, copying the raw bytes into owned storage +template inline trx::TypedArray make_typed_array(const std::vector &values, int cols = 1) { + trx::TypedArray arr; + arr.dtype = trx::dtype_from_scalar(); + arr.rows = static_cast(values.size()) / cols; + arr.cols = cols; + const auto *bytes = reinterpret_cast(values.data()); + arr.owned.assign(bytes, bytes + values.size() * sizeof(T)); + return arr; +} + +// Append per-streamline data to an existing TRX file (zip or directory) +template +inline void append_dps(std::string_view path, const std::string &name, const std::vector &values) { + const std::string filename(path); + std::map dps_map; + dps_map.emplace(name, make_typed_array(values)); + try { + if (trx::is_trx_directory(filename)) + trx::append_dps_to_directory(filename, dps_map); + else + trx::append_dps_to_zip(filename, dps_map); + } catch (const std::exception &e) { + throw Exception(std::string("Failed to append dps to TRX: ") + e.what()); + } +} + +// Append per-vertex data to an existing TRX file (zip or directory) +template +inline void append_dpv(std::string_view path, const std::string &name, const std::vector &values) { + const std::string filename(path); + std::map dpv_map; + dpv_map.emplace(name, make_typed_array(values)); + try { + if (trx::is_trx_directory(filename)) + trx::append_dpv_to_directory(filename, dpv_map); + else + trx::append_dpv_to_zip(filename, dpv_map); + } catch (const std::exception &e) { + throw Exception(std::string("Failed to append dpv to TRX: ") + e.what()); + } +} + +// Append a named group (list of streamline indices) to an existing TRX file. +// This is the per-group analogue of append_dps / append_dpv. +inline void +append_group(std::string_view trx_path, const std::string &name, const std::vector &streamline_indices) { + const std::string filename(trx_path); + std::map> groups_map; + groups_map.emplace(name, streamline_indices); + try { + if (trx::is_trx_directory(filename)) + trx::append_groups_to_directory(filename, groups_map); + else + trx::append_groups_to_zip(filename, groups_map); + } catch (const std::exception &e) { + throw Exception(std::string("Failed to append group to TRX: ") + e.what()); + } +} + +// Copy sidecar metadata (dps, groups, optionally dpv) from src_path to dst_path. +// +// Intended for geometry-modifying commands (tckresample, future tcktransform stream +// path) that stream warped/resampled geometry into a new TRX via TrxStream and then +// need to carry metadata forward from the source. +// +// Policy: +// - dps: always copied (streamline count is unchanged by geometry ops) +// - groups: always copied (streamline index sets are unchanged) +// - dpg: NOT copied — trx-cpp has no append_dpg_to_zip/directory API yet +// - dpv: copied only if include_dpv == true; callers that change vertex count +// (e.g. tckresample) should pass false and warn separately +// +// All numeric fields are copied as float32 regardless of original on-disk dtype, +// because TrxFile already presents all values as float. +// Group indices are copied as uint32_t. +inline void copy_trx_sidecar_data(std::string_view src_path, std::string_view dst_path, bool include_dpv = false) { + auto src = load_trx_header_only(src_path); + if (!src) + return; + const std::string dst(dst_path); + const bool is_dir = trx::is_trx_directory(dst); + + // --- dps --- + if (!src->data_per_streamline.empty()) { + std::map dps_map; + for (const auto &[name, arr] : src->data_per_streamline) { + if (!arr) + continue; + const auto &mat = arr->_matrix; + const int rows = static_cast(mat.rows()); + const int cols = static_cast(mat.cols()); + std::vector vals(static_cast(rows * cols)); + for (int r = 0; r < rows; ++r) + for (int c = 0; c < cols; ++c) + vals[static_cast(r * cols + c)] = static_cast(mat(r, c)); + dps_map.emplace(name, make_typed_array(vals, cols)); + } + try { + if (is_dir) + trx::append_dps_to_directory(dst, dps_map); + else + trx::append_dps_to_zip(dst, dps_map); + } catch (const std::exception &e) { + throw Exception(std::string("Failed to copy dps to TRX: ") + e.what()); + } + } + + // --- groups --- + if (!src->groups.empty()) { + std::map> groups_map; + for (const auto &[name, _] : src->groups) { + const auto *members = src->get_group_members(name); + if (!members) + continue; + const auto &mat = members->_matrix; + std::vector indices(static_cast(mat.rows())); + for (Eigen::Index i = 0; i < mat.rows(); ++i) + indices[static_cast(i)] = static_cast(mat(i, 0)); + groups_map.emplace(name, std::move(indices)); + } + try { + if (is_dir) + trx::append_groups_to_directory(dst, groups_map); + else + trx::append_groups_to_zip(dst, groups_map); + } catch (const std::exception &e) { + throw Exception(std::string("Failed to copy groups to TRX: ") + e.what()); + } + if (!src->data_per_group.empty()) + WARN(std::to_string(src->data_per_group.size()) + + " data_per_group field(s) not copied: append_dpg_to_zip/directory not yet available"); + } + + // --- dpv --- + if (include_dpv && !src->data_per_vertex.empty()) { + std::map dpv_map; + for (const auto &[name, arr] : src->data_per_vertex) { + if (!arr) + continue; + const auto &data = arr->_data; + const int rows = static_cast(data.rows()); + const int cols = static_cast(data.cols()); + std::vector vals(static_cast(rows * cols)); + for (int r = 0; r < rows; ++r) + for (int c = 0; c < cols; ++c) + vals[static_cast(r * cols + c)] = static_cast(data(r, c)); + dpv_map.emplace(name, make_typed_array(vals, cols)); + } + try { + if (is_dir) + trx::append_dpv_to_directory(dst, dpv_map); + else + trx::append_dpv_to_zip(dst, dpv_map); + } catch (const std::exception &e) { + throw Exception(std::string("Failed to copy dpv to TRX: ") + e.what()); + } + } +} + +// Check whether a loaded TRX file has any auxiliary data (groups, dps, dpv, dpg) +inline bool 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()); +} + +// Print TRX file information to an output stream. +// Choose the most granular prefix depth whose output stays within max_lines. +// Returns 0 if the flat group list already fits, or the largest depth value +// (starting from 1) where the collapsed output has ≤ max_lines lines. +// If even depth 1 exceeds max_lines, depth 1 is returned as the most compact +// option available. group_counts maps each group name to its streamline count. +inline int auto_prefix_depth(const std::map &group_counts, size_t max_lines = 100) { + if (group_counts.size() <= max_lines) + return 0; + int best = 1; + for (int depth = 1; depth <= 20; ++depth) { + const std::string s = trx::format_groups_summary(group_counts, depth); + const size_t n_lines = static_cast(std::count(s.begin(), s.end(), '\n')) + 1; + if (n_lines > max_lines) + break; // line count is non-decreasing with depth; no higher depth will help + best = depth; + } + return best; +} + +// When prefix_depth > 0, groups are collapsed by their underscore-delimited +// name prefix to the given depth (see trx::format_groups_summary). +// Set depth_auto = true when the depth was chosen automatically; a note is +// then appended to the Groups header line to inform the user. +inline void +print_info(std::ostream &out, const trx::TrxFile &trx, int prefix_depth = 0, bool depth_auto = false) { + out << " TRX streamlines: " << trx.num_streamlines() << "\n"; + out << " TRX vertices: " << trx.num_vertices() << "\n"; + + const auto &hdr = trx.header; + if (hdr["VOXEL_TO_RASMM"].is_array()) { + out << " VOXEL_TO_RASMM: "; + const auto &rows = hdr["VOXEL_TO_RASMM"].array_items(); + for (size_t r = 0; r < rows.size(); ++r) { + if (r > 0) + out << " "; + const auto &cols = rows[r].array_items(); + out << "["; + for (size_t c = 0; c < cols.size(); ++c) { + if (c > 0) + out << ", "; + out << cols[c].number_value(); + } + out << "]\n"; + } + } + + if (hdr["DIMENSIONS"].is_array()) { + out << " DIMENSIONS: ["; + const auto &dims = hdr["DIMENSIONS"].array_items(); + for (size_t i = 0; i < dims.size(); ++i) { + if (i > 0) + out << ", "; + out << dims[i].int_value(); + } + out << "]\n"; + } + + const auto &meta = hdr["metadata"]; + if (meta.is_object() && !meta.object_items().empty()) { + out << " Metadata:\n"; + for (const auto &[key, val] : meta.object_items()) + out << " " << key << ": " << val.string_value() << "\n"; + } + + if (!trx.groups.empty()) { + out << " Groups (" << trx.groups.size() << ")"; + if (depth_auto && prefix_depth > 0) + out << " [use -prefix_depth 0 to see all, or -prefix_depth N for a different depth]"; + out << ":\n"; + std::map group_counts; + for (const auto &kv : trx.groups) { + const auto *members = trx.get_group_members(kv.first); + group_counts[kv.first] = members ? static_cast(members->_matrix.rows()) : 0; + } + out << trx::format_groups_summary(group_counts, prefix_depth, " ") << "\n"; + } + + if (!trx.data_per_streamline.empty()) { + out << " Data per streamline (" << trx.data_per_streamline.size() << "):\n"; + for (const auto &kv : trx.data_per_streamline) { + size_t rows = kv.second ? static_cast(kv.second->_matrix.rows()) : 0; + size_t cols = kv.second ? static_cast(kv.second->_matrix.cols()) : 0; + out << " " << kv.first << ": " << rows << " x " << cols << "\n"; + } + } + + if (!trx.data_per_vertex.empty()) { + out << " Data per vertex (" << trx.data_per_vertex.size() << "):\n"; + for (const auto &kv : trx.data_per_vertex) { + size_t rows = kv.second ? static_cast(kv.second->_data.rows()) : 0; + size_t cols = kv.second ? static_cast(kv.second->_data.cols()) : 0; + out << " " << kv.first << ": " << rows << " x " << cols << "\n"; + } + } + + if (!trx.data_per_group.empty()) { + out << " Data per group (" << trx.data_per_group.size() << "):\n"; + for (const auto &group_kv : trx.data_per_group) { + out << " " << group_kv.first << ":\n"; + for (const auto &field_kv : group_kv.second) { + size_t rows = field_kv.second ? static_cast(field_kv.second->_matrix.rows()) : 0; + size_t cols = field_kv.second ? static_cast(field_kv.second->_matrix.cols()) : 0; + out << " " << field_kv.first << ": " << rows << " x " << cols << "\n"; + } + } + } +} + +// Reader class that wraps trx::TrxFile for streamline-by-streamline iteration. +// +// Optional weight injection: if a non-empty weight vector is provided at +// construction time, tck.weight is set from that vector during each operator() +// call. This mirrors the behaviour of Reader, which loads weights from +// the -tck_weights_in option in its own constructor. Use open_tractogram() with +// the weight_field_or_path parameter rather than constructing TRXReader directly. +class TRXReader : public ReaderInterface { +public: + explicit TRXReader(std::string_view file, std::vector weights = {}) + : trx(nullptr), + current(0), + num_streamlines_(0), + has_aux_data_(false), + weights_(std::move(weights)), + warned_short_weights_(false), + warned_excess_weights_(false) { + try { + trx = load_trx(file); + if (trx && trx->streamlines && trx->streamlines->_offsets.size() > 0) + num_streamlines_ = trx->streamlines->_offsets.size() - 1; + has_aux_data_ = has_aux_data(trx.get()); + } catch (const std::exception &e) { + throw Exception(e.what()); + } + } + + bool operator()(Streamline &tck) override { + tck.clear(); + if (!trx || current >= num_streamlines_) { + if (!warned_excess_weights_ && !weights_.empty() && weights_.size() > static_cast(num_streamlines_)) { + WARN("Streamline weights file contains more entries (" + str(weights_.size()) + ") than TRX file (" + + str(num_streamlines_) + ")"); + warned_excess_weights_ = true; + } + return false; + } + tck.set_index(static_cast(current)); + // Inject per-streamline weight if weights were resolved at construction time + if (!weights_.empty()) { + if (static_cast(current) < weights_.size()) { + tck.weight = weights_[static_cast(current)]; + } else { + if (!warned_short_weights_) { + WARN("Streamline weights file contains less entries (" + str(weights_.size()) + + ") than TRX file; ceasing reading of streamline data"); + warned_short_weights_ = true; + } + tck.clear(); + return false; + } + } else { + tck.weight = 1.0f; + } + 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; + } + + size_t num_streamlines() const { return static_cast(num_streamlines_); } + + // Populate a Properties map from the TRX header "metadata" object. + // This transfers fields written by tckgen/tckconvert (step_size, method, etc.) + // so callers can use them exactly as they would TCK properties. + void populate_properties(Tractography::Properties &properties) const { + properties["count"] = std::to_string(num_streamlines_); + if (!trx) + return; + const auto &meta = trx->header["metadata"]; + if (!meta.is_object()) + return; + for (const auto &[key, val] : meta.object_items()) { + if (val.is_string()) + properties[key] = val.string_value(); + } + } + + ~TRXReader() override { + 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_; } + + trx::TrxFile *get_trx() { return trx.get(); } + +private: + std::unique_ptr> trx; + Eigen::Index current; + Eigen::Index num_streamlines_; + bool has_aux_data_; + std::vector weights_; // per-streamline weights injected into tck.weight during iteration + bool warned_short_weights_; + bool warned_excess_weights_; +}; + +// Writer class that accumulates streamlines into a preallocated TrxFile +class TRXWriter : public WriterInterface { +public: + TRXWriter(std::string_view file, + size_t nb_streamlines, + size_t nb_vertices, + 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; + } catch (const std::exception &e) { + throw Exception(e.what()); + } + } + + bool operator()(const Streamline &tck) override { + 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() override { + 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; + } + } + + trx::TrxFile *get_trx() { return trx.get(); } + +private: + std::string output; + std::string final_output; + bool rename_on_save; + std::unique_ptr> trx; + Eigen::Index current_streamline; + Eigen::Index current_vertex; +}; + +// Open a tractogram (TCK or TRX) for streaming, populating properties. +// For TRX: properties["count"] and any fields stored in header["metadata"] +// (e.g. step_size, method, command_history) are transferred to properties, +// so callers can treat TRX and TCK properties identically. +// For TCK: identical to constructing Reader(path, properties) directly. +// This is the single entry point replacing Reader file(path, properties) in commands +// that want to accept both formats without any format-detection branching. +// See the 3-argument overload below (defined after resolve_dps_weights) for weight injection. +inline std::unique_ptr> open_tractogram(std::string_view path, + Tractography::Properties &properties) { + if (is_trx(path)) { + auto reader = std::make_unique(path); + reader->populate_properties(properties); + return reader; + } + return std::make_unique>(path, properties); +} + +// Resolve per-streamline weights from either an external text file or a named dps field +// in the TRX file at tractogram_path. +// +// Resolution order: +// 1. If field_name_or_path is an existing file path → load as a text weight vector +// (same format as -tck_weights_in). +// 2. Else if tractogram_path is a TRX file → look up field_name_or_path in data_per_streamline. +// 3. Otherwise → throw. +// +// Returns an empty vector when field_name_or_path is empty (no weights requested). +inline std::vector resolve_dps_weights(std::string_view tractogram_path, const std::string &field_name_or_path) { + if (field_name_or_path.empty()) + return {}; + if (Path::exists(field_name_or_path)) { + const auto eig = File::Matrix::load_vector(field_name_or_path); + return std::vector(eig.data(), eig.data() + eig.size()); + } + if (!is_trx(tractogram_path)) + throw Exception("cannot resolve \"" + field_name_or_path + "\": not an existing file and input is not a TRX file"); + auto trx = load_trx_header_only(tractogram_path); + auto it = trx->data_per_streamline.find(field_name_or_path); + if (it == trx->data_per_streamline.end() || !it->second) + throw Exception("TRX file has no dps field named \"" + field_name_or_path + "\""); + const auto &mat = it->second->_matrix; + if (mat.cols() > 1) + WARN("dps field \"" + field_name_or_path + "\" has " + std::to_string(mat.cols()) + + " columns; using first column only"); + std::vector result(static_cast(mat.rows())); + for (Eigen::Index i = 0; i < mat.rows(); ++i) + result[static_cast(i)] = mat(i, 0); + return result; +} + +// Weight-injecting overload of open_tractogram. Defined here (after resolve_dps_weights) +// because it calls resolve_dps_weights for TRX input. +// +// For TRX: weight_field_or_path is resolved via resolve_dps_weights() and the resulting +// vector is passed to TRXReader, which injects tck.weight during each operator() call. +// This is exactly what Reader does automatically for TCK files. +// For TCK: weight_field_or_path is ignored; Reader reads -tck_weights_in itself. +// There is no double-loading. +// +// Callers that need weight injection (e.g. tckmap) use this overload: +// auto reader = open_tractogram(path, properties, get_option_value("tck_weights_in", "")); +// Callers that do not need weights use the 2-argument overload above. +inline std::unique_ptr> +open_tractogram(std::string_view path, Tractography::Properties &properties, const std::string &weight_field_or_path) { + if (!is_trx(path)) + return open_tractogram(path, properties); // TCK: Reader handles weights itself + auto weights = resolve_dps_weights(path, weight_field_or_path); + auto reader = std::make_unique(path, std::move(weights)); + reader->populate_properties(properties); + return reader; +} + +// Resolve flat per-vertex scalars from a named dpv field in the TRX file at tractogram_path. +// The returned vector is ordered by streamline then vertex, matching the TRX storage layout +// (i.e. suitable for direct use as a TrackScalar or TSF replacement). +// +// Resolution order: +// 1. If field_name_or_path is an existing file path → load as a TSF scalar file. +// (TSF binary loading is handled transparently; the path must end in .tsf.) +// 2. Else if tractogram_path is a TRX file → look up field_name_or_path in data_per_vertex. +// 3. Otherwise → throw. +// +// Returns an empty vector when field_name_or_path is empty (no scalars requested). +inline std::vector resolve_dpv_scalars(std::string_view tractogram_path, const std::string &field_name_or_path) { + if (field_name_or_path.empty()) + return {}; + if (Path::exists(field_name_or_path)) { + // External TSF binary file: load as a flat vector via the text/matrix path. + // TSF files are plain binary float arrays with a small header; the existing + // File::Matrix::load_vector handles the common text case. For proper binary + // TSF support, callers should use Tractography::ScalarFile directly. + const auto eig = File::Matrix::load_vector(field_name_or_path); + return std::vector(eig.data(), eig.data() + eig.size()); + } + if (!is_trx(tractogram_path)) + throw Exception("cannot resolve \"" + field_name_or_path + "\": not an existing file and input is not a TRX file"); + auto trx = load_trx_header_only(tractogram_path); + auto it = trx->data_per_vertex.find(field_name_or_path); + if (it == trx->data_per_vertex.end() || !it->second) + throw Exception("TRX file has no dpv field named \"" + field_name_or_path + "\""); + const auto &data = it->second->_data; + if (data.cols() > 1) + WARN("dpv field \"" + field_name_or_path + "\" has " + std::to_string(data.cols()) + + " columns; using first column only"); + std::vector result(static_cast(data.rows())); + for (Eigen::Index i = 0; i < data.rows(); ++i) + result[static_cast(i)] = data(i, 0); + return result; +} + +// Read a TRX dpv field as a stream of per-streamline TrackScalar. +// Copies dpv values and streamline offsets into owned vectors on construction so +// that the TRX mmap can be released immediately (enabling in-place append later). +class TRXScalarReader { +public: + TRXScalarReader(std::string_view trx_path, const std::string &field_name) : current_(0) { + auto trx = load_trx_header_only(trx_path); + if (!trx || !trx->streamlines) + throw Exception("Failed to load TRX file: " + std::string(trx_path)); + + auto it = trx->data_per_vertex.find(field_name); + if (it == trx->data_per_vertex.end() || !it->second) + throw Exception("TRX file has no dpv field '" + field_name + "'"); + const auto &data = it->second->_data; + if (data.cols() > 1) + WARN("dpv field '" + field_name + "' has " + std::to_string(data.cols()) + " columns; using first column only"); + + // Copy vertex values and offsets into owned storage so the mmap can be released. + const Eigen::Index nv = data.rows(); + values_.resize(static_cast(nv)); + for (Eigen::Index i = 0; i < nv; ++i) + values_[static_cast(i)] = data(i, 0); + + n_streamlines_ = static_cast(trx->num_streamlines()); + offsets_.resize(static_cast(n_streamlines_ + 1)); + for (Eigen::Index i = 0; i <= n_streamlines_; ++i) + offsets_[static_cast(i)] = static_cast(trx->streamlines->_offsets(i, 0)); + + trx->close(); + } + + bool operator()(Tractography::TrackScalar &scalar) { + scalar.clear(); + if (current_ >= n_streamlines_) + return false; + const size_t start = offsets_[static_cast(current_)]; + const size_t end = offsets_[static_cast(current_ + 1)]; + scalar.set_index(static_cast(current_)); + scalar.reserve(end - start); + for (size_t i = start; i < end; ++i) + scalar.push_back(values_[i]); + ++current_; + return true; + } + + size_t num_streamlines() const { return static_cast(n_streamlines_); } + size_t num_vertices() const { return values_.size(); } + +private: + std::vector values_; + std::vector offsets_; + Eigen::Index current_; + Eigen::Index n_streamlines_; +}; + +// Accumulate per-streamline TrackScalar values and append them as a +// dpv field to an existing TRX file. The output TRX file must already exist +// (use the same path as the input to add a field in-place). +// Call finalize() when all scalars have been written; the destructor also calls +// finalize() as a fallback (but errors in the destructor are suppressed). +class TRXScalarWriter { +public: + TRXScalarWriter(std::string_view trx_path, const std::string &field_name) + : path_(trx_path), field_(field_name), finalized_(false) { + if (!is_trx(trx_path)) + throw Exception("Output path for TRX scalar writer is not a TRX file: " + std::string(trx_path)); + if (!Path::exists(std::string(trx_path))) + throw Exception("Output TRX file '" + std::string(trx_path) + + "' does not exist; cannot append dpv. " + "Use the same path as the input TRX to add a field in-place."); + } + + bool operator()(const Tractography::TrackScalar &scalar) { + buffer_.insert(buffer_.end(), scalar.begin(), scalar.end()); + return true; + } + + void finalize() { + if (finalized_) + return; + finalized_ = true; + append_dpv(path_, field_, buffer_); + } + + ~TRXScalarWriter() { + if (!finalized_) { + try { + finalize(); + } catch (const std::exception &e) { + Exception(e.what()).display(); + App::exit_error_code = 1; + } + } + } + +private: + std::string path_; + std::string field_; + std::vector buffer_; + bool finalized_; +}; + +} // namespace MR::DWI::Tractography::TRX diff --git a/cpp/core/dwi/tractography/weights.cpp b/cpp/core/dwi/tractography/weights.cpp index 0be77fb91b..7fe8451507 100644 --- a/cpp/core/dwi/tractography/weights.cpp +++ b/cpp/core/dwi/tractography/weights.cpp @@ -23,8 +23,9 @@ using namespace App; // clang-format off const Option TrackWeightsInOption = Option("tck_weights_in", - "specify a text scalar file containing the streamline weights") - + Argument("path").type_file_in(); + "specify a text scalar file containing the streamline weights, " + "or (for TRX inputs) the name of a data_per_streamline field") + + Argument("path").type_text(); const Option TrackWeightsOutOption = Option("tck_weights_out", diff --git a/cpp/core/fixel/matrix.cpp b/cpp/core/fixel/matrix.cpp index 9a712efdd4..d61286178d 100644 --- a/cpp/core/fixel/matrix.cpp +++ b/cpp/core/fixel/matrix.cpp @@ -27,6 +27,7 @@ #include "dwi/tractography/mapping/mapper.h" #include "dwi/tractography/mapping/voxel.h" #include "dwi/tractography/streamline.h" +#include "dwi/tractography/trx_utils.h" namespace MR::Fixel::Matrix { @@ -192,9 +193,12 @@ template class Receiver { .template get_image() \ .with_direct_io({+2, +1}); \ DWI::Tractography::Properties properties; \ - DWI::Tractography::Reader track_file(track_filename, properties); \ + auto wt_opt = App::get_options("tck_weights_in"); \ + const std::string weight_src = wt_opt.empty() ? "" : std::string(wt_opt[0][0]); \ + auto track_reader = DWI::Tractography::TRX::open_tractogram(track_filename, properties, weight_src); \ const uint32_t num_tracks = properties["count"].empty() ? 0 : to(properties["count"]); \ - DWI::Tractography::Mapping::TrackLoader loader(track_file, num_tracks, "computing fixel-fixel connectivity matrix"); \ + DWI::Tractography::Mapping::TrackLoader loader( \ + *track_reader, num_tracks, "computing fixel-fixel connectivity matrix"); \ DWI::Tractography::Mapping::TrackMapperBase mapper(index_image); \ mapper.set_upsample_ratio(DWI::Tractography::Mapping::determine_upsample_ratio(index_image, properties, 0.333f)); \ mapper.set_use_precise_mapping(true); \ diff --git a/cpp/core/mrtrix.cpp b/cpp/core/mrtrix.cpp index 613b289b40..6dba6a5d44 100644 --- a/cpp/core/mrtrix.cpp +++ b/cpp/core/mrtrix.cpp @@ -226,7 +226,7 @@ size_t dash_bytes(std::string_view arg) { return 1; if (arg.size() < 3) return 0; - std::basic_string_view uarg(reinterpret_cast(arg.data()), arg.size()); + const auto *uarg = reinterpret_cast(arg.data()); if (uarg[0] == 0xE2 && uarg[1] == 0x80 && (uarg[2] >= 0x90 && uarg[2] <= 0x95)) return 3; if (uarg[0] == 0xEF) { diff --git a/cpp/gui/mrview/tool/tractography/track_group_options.cpp b/cpp/gui/mrview/tool/tractography/track_group_options.cpp new file mode 100644 index 0000000000..e537c81710 --- /dev/null +++ b/cpp/gui/mrview/tool/tractography/track_group_options.cpp @@ -0,0 +1,182 @@ +/* Copyright (c) 2008-2026 the MRtrix3 contributors. + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + * + * Covered Software is provided under this License on an "as is" + * basis, without warranty of any kind, either expressed, implied, or + * statutory, including, without limitation, warranties that the + * Covered Software is free of defects, merchantable, fit for a + * particular purpose or non-infringing. + * See the Mozilla Public License v. 2.0 for more details. + * + * For more details, see http://www.mrtrix.org/. + */ + +#include "mrview/tool/tractography/track_group_options.h" + +#include +#include +#include + +#include "mrview/tool/tractography/tractogram.h" +#include "mrview/tool/tractography/tractography.h" +#include "mrview/window.h" + +namespace MR::GUI::MRView::Tool { + +TrackGroupOptions::TrackGroupOptions(Tractography *parent) + : QGroupBox("TRX Groups", parent), tool(parent), tractogram(nullptr) { + + auto *main_layout = new QVBoxLayout(this); + main_layout->setContentsMargins(4, 4, 4, 4); + main_layout->setSpacing(4); + + auto *policy_row = new QHBoxLayout; + policy_row->addWidget(new QLabel("Multi-group:")); + multi_policy_combo = new QComboBox(this); + multi_policy_combo->addItem("First match"); + multi_policy_combo->addItem("Last match"); + connect(multi_policy_combo, SIGNAL(activated(int)), this, SLOT(multi_policy_changed(int))); + policy_row->addWidget(multi_policy_combo); + main_layout->addLayout(policy_row); + + rows_widget = new QWidget; + rows_layout = new QVBoxLayout(rows_widget); + rows_layout->setContentsMargins(0, 0, 0, 0); + rows_layout->setSpacing(1); + rows_layout->addStretch(1); + + scroll_area = new QScrollArea(this); + scroll_area->setWidgetResizable(true); + scroll_area->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); + scroll_area->setMaximumHeight(160); + scroll_area->setWidget(rows_widget); + main_layout->addWidget(scroll_area); + + show_ungrouped_box = new QCheckBox("Show ungrouped", this); + show_ungrouped_box->setChecked(true); + connect(show_ungrouped_box, SIGNAL(stateChanged(int)), this, SLOT(show_ungrouped_changed(int))); + main_layout->addWidget(show_ungrouped_box); + + setVisible(false); +} + +Window &TrackGroupOptions::window() const { return *Window::main; } + +void TrackGroupOptions::set_tractogram(Tractogram *t) { tractogram = t; } + +void TrackGroupOptions::update_UI() { + if (!tractogram || !tractogram->is_trx() || tractogram->trx_group_names().empty()) { + setVisible(false); + return; + } + setVisible(true); + + if (tractogram->group_states.empty()) + tractogram->init_group_states(); + + multi_policy_combo->blockSignals(true); + multi_policy_combo->setCurrentIndex(tractogram->group_multi_policy == GroupMultiPolicy::FirstMatch ? 0 : 1); + multi_policy_combo->blockSignals(false); + + show_ungrouped_box->blockSignals(true); + show_ungrouped_box->setChecked(tractogram->show_ungrouped); + show_ungrouped_box->blockSignals(false); + + rebuild_rows(); +} + +void TrackGroupOptions::rebuild_rows() { + for (auto *w : row_widgets) { + rows_layout->removeWidget(w); + delete w; + } + row_widgets.clear(); + + if (!tractogram) + return; + + for (const auto &name : tractogram->group_order) { + auto git = tractogram->group_states.find(name); + if (git == tractogram->group_states.end()) + continue; + const auto &gs = git->second; + + auto *row = new QWidget(rows_widget); + auto *hl = new QHBoxLayout(row); + hl->setContentsMargins(0, 0, 0, 0); + hl->setSpacing(4); + + const std::string label_text = name + " (" + std::to_string(gs.count) + ")"; + auto *cb = new QCheckBox(QString::fromStdString(label_text), row); + cb->setChecked(gs.visible); + + const std::string group_name = name; // capture by value + connect(cb, &QCheckBox::stateChanged, this, [this, group_name](int state) { + if (!tractogram) + return; + auto it = tractogram->group_states.find(group_name); + if (it != tractogram->group_states.end()) + it->second.visible = (state == Qt::Checked); + tractogram->reload_group_colours(); + window().updateGL(); + }); + hl->addWidget(cb, 1); + + auto *swatch = new QPushButton(row); + swatch->setFixedSize(20, 20); + const auto &c = gs.color; + const int r = static_cast(c[0] * 255.0f); + const int g = static_cast(c[1] * 255.0f); + const int b = static_cast(c[2] * 255.0f); + swatch->setStyleSheet( + QString("QPushButton { background-color: rgb(%1,%2,%3); border: 1px solid #888; }").arg(r).arg(g).arg(b)); + + connect(swatch, &QPushButton::clicked, this, [this, group_name, swatch]() { + if (!tractogram) + return; + auto it = tractogram->group_states.find(group_name); + if (it == tractogram->group_states.end()) + return; + const auto &old_c = it->second.color; + const QColor initial(static_cast(old_c[0] * 255.0f), + static_cast(old_c[1] * 255.0f), + static_cast(old_c[2] * 255.0f)); + const QColor chosen = + QColorDialog::getColor(initial, this, "Select group colour", QColorDialog::DontUseNativeDialog); + if (!chosen.isValid()) + return; + it->second.color = {chosen.redF(), chosen.greenF(), chosen.blueF()}; + swatch->setStyleSheet(QString("QPushButton { background-color: rgb(%1,%2,%3); border: 1px solid #888; }") + .arg(chosen.red()) + .arg(chosen.green()) + .arg(chosen.blue())); + tractogram->reload_group_colours(); + window().updateGL(); + }); + hl->addWidget(swatch); + + rows_layout->insertWidget(rows_layout->count() - 1, row); // insert before stretch + row_widgets.push_back(row); + } +} + +void TrackGroupOptions::multi_policy_changed(int index) { + if (!tractogram) + return; + tractogram->group_multi_policy = (index == 0) ? GroupMultiPolicy::FirstMatch : GroupMultiPolicy::LastMatch; + tractogram->reload_group_colours(); + window().updateGL(); +} + +void TrackGroupOptions::show_ungrouped_changed(int state) { + if (!tractogram) + return; + tractogram->show_ungrouped = (state == Qt::Checked); + tractogram->reload_group_colours(); + window().updateGL(); +} + +} // namespace MR::GUI::MRView::Tool diff --git a/cpp/gui/mrview/tool/tractography/track_group_options.h b/cpp/gui/mrview/tool/tractography/track_group_options.h new file mode 100644 index 0000000000..5e5d44a73e --- /dev/null +++ b/cpp/gui/mrview/tool/tractography/track_group_options.h @@ -0,0 +1,69 @@ +/* Copyright (c) 2008-2026 the MRtrix3 contributors. + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + * + * Covered Software is provided under this License on an "as is" + * basis, without warranty of any kind, either expressed, implied, or + * statutory, including, without limitation, warranties that the + * Covered Software is free of defects, merchantable, fit for a + * particular purpose or non-infringing. + * See the Mozilla Public License v. 2.0 for more details. + * + * For more details, see http://www.mrtrix.org/. + */ + +#pragma once + +#include + +#include +#include +#include +#include +#include +#include + +namespace MR::GUI::MRView { +class Window; +} // namespace MR::GUI::MRView + +namespace MR::GUI::MRView::Tool { +class Tractogram; +class Tractography; + +// Panel that appears when a TRX tractogram with groups is selected. +// Provides per-group visibility toggles, color swatches, and multi-group +// priority policy. Shown/hidden by Tractography::update_scalar_options(). +class TrackGroupOptions : public QGroupBox { + Q_OBJECT + +public: + TrackGroupOptions(Tractography *parent); + + void set_tractogram(Tractogram *t); + void update_UI(); + +private slots: + void multi_policy_changed(int index); + void show_ungrouped_changed(int state); + +private: + void rebuild_rows(); + + Tractography *tool; + Tractogram *tractogram; + + QScrollArea *scroll_area; + QWidget *rows_widget; + QVBoxLayout *rows_layout; + QCheckBox *show_ungrouped_box; + QComboBox *multi_policy_combo; + + std::vector row_widgets; + + Window &window() const; +}; + +} // namespace MR::GUI::MRView::Tool diff --git a/cpp/gui/mrview/tool/tractography/track_scalar_file.cpp b/cpp/gui/mrview/tool/tractography/track_scalar_file.cpp index 37460e28ef..d095089f16 100644 --- a/cpp/gui/mrview/tool/tractography/track_scalar_file.cpp +++ b/cpp/gui/mrview/tool/tractography/track_scalar_file.cpp @@ -16,8 +16,11 @@ #include "mrview/tool/tractography/track_scalar_file.h" +#include + #include "dialog/file.h" #include "mrview/tool/tractography/tractogram.h" +#include "mrview/tool/tractography/tractography.h" namespace MR::GUI::MRView::Tool { @@ -31,6 +34,14 @@ TrackScalarFileOptions::TrackScalarFileOptions(Tractography *parent) vlayout->setSpacing(0); colour_groupbox->setLayout(vlayout); + // "TRX field…" button lives outside colour_groupbox so it is always visible + // when a TRX tractogram is selected, regardless of the current colour mode. + trx_field_button = new QPushButton("TRX field\u2026", this); + trx_field_button->setToolTip(tr("Select an embedded TRX dps or dpv field for colouring streamlines")); + connect(trx_field_button, SIGNAL(clicked()), this, SLOT(open_trx_scalar_field_slot())); + trx_field_button->setVisible(false); + main_box->addWidget(trx_field_button); + Tool::Base::HBoxLayout *hlayout = new Tool::Base::HBoxLayout; hlayout->setContentsMargins(0, 0, 0, 0); hlayout->setSpacing(0); @@ -130,6 +141,7 @@ void TrackScalarFileOptions::update_UI() { return; } setVisible(true); + trx_field_button->setVisible(tractogram->is_trx()); if (tractogram->get_color_type() == TrackColourType::ScalarFile) { @@ -144,9 +156,13 @@ void TrackScalarFileOptions::update_UI() { colourmap_button->set_scale_inverted(tractogram->scale_inverted()); colourmap_button->set_show_colourbar(tractogram->show_colour_bar); - assert(tractogram->intensity_scalar_filename.length()); - intensity_file_button->setText(qstr(shorten(Path::basename(tractogram->intensity_scalar_filename), 35, 0))); - intensity_file_button->setToolTip(qstr(tractogram->intensity_scalar_filename)); + if (tractogram->intensity_scalar_filename.empty()) { + intensity_file_button->setText(tr("(no file)")); + intensity_file_button->setToolTip(tr("Open (track) scalar file for colouring streamlines")); + } else { + intensity_file_button->setText(qstr(shorten(Path::basename(tractogram->intensity_scalar_filename), 35, 0))); + intensity_file_button->setToolTip(qstr(tractogram->intensity_scalar_filename)); + } } else { colour_groupbox->setVisible(false); @@ -389,4 +405,89 @@ void TrackScalarFileOptions::toggle_invert_colourmap(bool invert, const ColourMa } } +void TrackScalarFileOptions::open_trx_scalar_field_slot() { + if (!tractogram || !tractogram->is_trx()) + return; + + // Build item list: "DPS: name" for per-streamline, "DPV: name" for per-vertex + QStringList items; + for (const auto &f : tractogram->trx_dps_fields()) + items << QString("DPS: %1").arg(QString::fromStdString(f)); + for (const auto &f : tractogram->trx_dpv_fields()) + items << QString("DPV: %1").arg(QString::fromStdString(f)); + + if (items.isEmpty()) { + QMessageBox::information(this, tr("TRX scalar field"), tr("This TRX file has no embedded dps or dpv fields.")); + return; + } + + bool ok = false; + QString chosen = QInputDialog::getItem(this, tr("Select TRX scalar field"), tr("Field:"), items, 0, false, &ok); + if (!ok || chosen.isEmpty()) + return; + + const bool is_dpv = chosen.startsWith("DPV: "); + const std::string field_name = chosen.mid(5).toStdString(); // strip "DPS: " or "DPV: " + + try { + tractogram->load_trx_scalar_field(field_name, is_dpv); + tractogram->set_color_type(TrackColourType::ScalarFile); + } catch (Exception &e) { + e.display(); + return; + } + update_UI(); + window().updateGL(); +} + +bool TrackScalarFileOptions::open_trx_scalar_field_by_name(const std::string &field_spec) { + if (!tractogram || !tractogram->is_trx()) + throw Exception("TRX scalar field selection requires one selected TRX tractogram"); + + std::string field_name = field_spec; + bool force_dps = false; + bool force_dpv = false; + + // Allow explicit type disambiguation via dps: or dpv:. + if (field_spec.rfind("dps:", 0) == 0) { + force_dps = true; + field_name = field_spec.substr(4); + } else if (field_spec.rfind("dpv:", 0) == 0) { + force_dpv = true; + field_name = field_spec.substr(4); + } + + if (field_name.empty()) + throw Exception("TRX scalar field name cannot be empty"); + + const auto &dps_fields = tractogram->trx_dps_fields(); + const auto &dpv_fields = tractogram->trx_dpv_fields(); + const bool has_dps = std::find(dps_fields.begin(), dps_fields.end(), field_name) != dps_fields.end(); + const bool has_dpv = std::find(dpv_fields.begin(), dpv_fields.end(), field_name) != dpv_fields.end(); + + bool is_dpv = false; + if (force_dps) { + if (!has_dps) + throw Exception("TRX file has no dps field named \"" + field_name + "\""); + is_dpv = false; + } else if (force_dpv) { + if (!has_dpv) + throw Exception("TRX file has no dpv field named \"" + field_name + "\""); + is_dpv = true; + } else { + if (!has_dps && !has_dpv) + throw Exception("TRX file has no scalar field named \"" + field_name + "\""); + if (has_dps && has_dpv) + throw Exception("TRX scalar field name \"" + field_name + + "\" exists in both dps and dpv; use dps: or dpv:"); + is_dpv = has_dpv; + } + + tractogram->load_trx_scalar_field(field_name, is_dpv); + tractogram->set_color_type(TrackColourType::ScalarFile); + update_UI(); + window().updateGL(); + return true; +} + } // namespace MR::GUI::MRView::Tool diff --git a/cpp/gui/mrview/tool/tractography/track_scalar_file.h b/cpp/gui/mrview/tool/tractography/track_scalar_file.h index 74d77fd1d9..c4e7e72908 100644 --- a/cpp/gui/mrview/tool/tractography/track_scalar_file.h +++ b/cpp/gui/mrview/tool/tractography/track_scalar_file.h @@ -41,6 +41,7 @@ class TrackScalarFileOptions : public QGroupBox, public ColourMapButtonObserver, void set_scaling(default_type min, default_type max); void set_threshold(GUI::MRView::Tool::TrackThresholdType dataSource, default_type min, default_type max); void set_colourmap(int colourmap_index) { colourmap_button->set_colourmap_index(colourmap_index); } + bool open_trx_scalar_field_by_name(const std::string &field_spec); void selected_colourmap(size_t, const ColourMapButton &) override; void selected_custom_colour(const QColor &, const ColourMapButton &) override; @@ -53,6 +54,7 @@ public slots: bool open_intensity_track_scalar_file_slot(std::string); private slots: + void open_trx_scalar_field_slot(); void on_set_scaling_slot(); bool threshold_scalar_file_slot(int); void threshold_lower_changed(int unused); @@ -69,6 +71,7 @@ private slots: QAction *invert_scale; ColourMapButton *colourmap_button; QPushButton *intensity_file_button; + QPushButton *trx_field_button; AdjustButton *max_entry, *min_entry; QComboBox *threshold_file_combobox; AdjustButton *threshold_lower, *threshold_upper; diff --git a/cpp/gui/mrview/tool/tractography/tractogram.cpp b/cpp/gui/mrview/tool/tractography/tractogram.cpp index 36bba94548..791df34fac 100644 --- a/cpp/gui/mrview/tool/tractography/tractogram.cpp +++ b/cpp/gui/mrview/tool/tractography/tractogram.cpp @@ -21,6 +21,7 @@ #include "dwi/tractography/file.h" #include "dwi/tractography/properties.h" #include "dwi/tractography/scalar_file.h" +#include "dwi/tractography/trx_utils.h" #include "file/matrix.h" #include "mrview/mode/base.h" #include "mrview/window.h" @@ -42,7 +43,7 @@ std::string Tractogram::Shader::vertex_shader_source(const Displayable &displaya "layout (location = 1) in vec3 prev_vertex;\n" "layout (location = 2) in vec3 next_vertex;\n"; - if (color_type == TrackColourType::Ends) + if (color_type == TrackColourType::Ends || color_type == TrackColourType::Group) source += "layout (location = 3) in vec3 end_colour;\n"; else if (color_type == TrackColourType::ScalarFile) source += "layout (location = 3) in float amp;\n"; @@ -71,7 +72,8 @@ std::string Tractogram::Shader::vertex_shader_source(const Displayable &displaya if (threshold_type != TrackThresholdType::None) source += "out float v_amp;\n"; - if (color_type == TrackColourType::Ends || color_type == TrackColourType::ScalarFile) + if (color_type == TrackColourType::Ends || color_type == TrackColourType::Group || + color_type == TrackColourType::ScalarFile) source += "out vec3 v_colour;\n"; // Main function @@ -90,7 +92,7 @@ std::string Tractogram::Shader::vertex_shader_source(const Displayable &displaya else if (threshold_type == TrackThresholdType::SeparateFile) source += " v_amp = thresh_amp;\n"; - if (color_type == TrackColourType::Ends) + if (color_type == TrackColourType::Ends || color_type == TrackColourType::Group) source += " v_colour = end_colour;\n"; else if (color_type == TrackColourType::ScalarFile) { // TODO: move to frag shader: if (!ColourMap::maps[colourmap].special) { @@ -131,7 +133,8 @@ std::string Tractogram::Shader::geometry_shader_source(const Displayable &) { if (use_lighting || color_type == TrackColourType::Direction) source += "out vec3 g_tangent;\n"; - if (color_type == TrackColourType::ScalarFile || color_type == TrackColourType::Ends) + if (color_type == TrackColourType::ScalarFile || color_type == TrackColourType::Ends || + color_type == TrackColourType::Group) source += "in vec3 v_colour[];\n" // check_syntax off "out vec3 fColour;\n"; @@ -153,7 +156,8 @@ std::string Tractogram::Shader::geometry_shader_source(const Displayable &) { source += " g_include = v_include[0];\n"; if (threshold_type != TrackThresholdType::None) source += " g_amp = v_amp[0];\n"; - if (color_type == TrackColourType::ScalarFile || color_type == TrackColourType::Ends) + if (color_type == TrackColourType::ScalarFile || color_type == TrackColourType::Ends || + color_type == TrackColourType::Group) source += " fColour = v_colour[0];\n"; if (use_lighting) @@ -173,7 +177,8 @@ std::string Tractogram::Shader::geometry_shader_source(const Displayable &) { source += " g_include = v_include[1];\n"; if (threshold_type != TrackThresholdType::None) source += " g_amp = v_amp[1];\n"; - if (color_type == TrackColourType::ScalarFile || color_type == TrackColourType::Ends) + if (color_type == TrackColourType::ScalarFile || color_type == TrackColourType::Ends || + color_type == TrackColourType::Group) source += " fColour = v_colour[1];\n"; if (use_lighting) @@ -200,7 +205,8 @@ std::string Tractogram::Shader::fragment_shader_source(const Displayable &displa "uniform mat4 MV;\n" "out vec3 colour;\n"; - if (color_type == TrackColourType::ScalarFile || color_type == TrackColourType::Ends) + if (color_type == TrackColourType::ScalarFile || color_type == TrackColourType::Ends || + color_type == TrackColourType::Group) source += using_geom ? "in vec3 fColour;\n" : "in vec3 v_colour;\n"; if (use_lighting || color_type == TrackColourType::Direction) source += using_geom ? "in vec3 g_tangent;\n" : "in vec3 v_tangent;\n"; @@ -248,6 +254,10 @@ std::string Tractogram::Shader::fragment_shader_source(const Displayable &displa case TrackColourType::Ends: source += using_geom ? " colour = fColour;\n" : " colour = v_colour;\n"; break; + case TrackColourType::Group: + source += using_geom ? " colour = fColour;\n" : " colour = v_colour;\n"; + source += " if (colour.r < 0.0) discard;\n"; // hidden-group sentinel + break; case TrackColourType::Manual: source += " colour = colourmap_colour;\n"; } @@ -326,7 +336,10 @@ Tractogram::Tractogram(Tractography &tool, std::string_view filename) sample_stride(0), vao_dirty(true), threshold_min(NaNF), - threshold_max(NaNF) { + threshold_max(NaNF), + colour_buffers_from_groups(false), + show_ungrouped(true), + group_multi_policy(GroupMultiPolicy::FirstMatch) { set_allowed_features(true, true, true); colourmap = 1; connect(&window(), SIGNAL(fieldOfViewChanged()), this, SLOT(on_FOV_changed())); @@ -458,6 +471,7 @@ inline void Tractogram::render_streamlines() { switch (color_type) { case TrackColourType::Ends: + case TrackColourType::Group: gl::BindBuffer(gl::ARRAY_BUFFER, colour_buffers[buf]); gl::EnableVertexAttribArray(3); gl::VertexAttribPointer(3, @@ -563,7 +577,18 @@ void Tractogram::load_tracks() { GL::Context::Grab context; GL::assert_context_is_current(); - DWI::Tractography::Reader file(filename, properties); + auto reader = DWI::Tractography::TRX::open_tractogram(filename, properties); + // Cache TRX field names while the reader (and its underlying TrxFile) is still alive + if (auto *trxr = dynamic_cast(reader.get())) { + if (const auto *trx = trxr->get_trx()) { + for (const auto &kv : trx->data_per_streamline) + cached_trx_dps_names.push_back(kv.first); + for (const auto &kv : trx->data_per_vertex) + cached_trx_dpv_names.push_back(kv.first); + for (const auto &kv : trx->groups) + cached_trx_group_names.push_back(kv.first); + } + } DWI::Tractography::Streamline tck; std::vector buffer; std::vector starts; @@ -572,7 +597,7 @@ void Tractogram::load_tracks() { on_FOV_changed(); - while (file(tck)) { + while ((*reader)(tck)) { const size_t N = tck.size(); if (!N) @@ -603,14 +628,18 @@ void Tractogram::load_tracks() { } if (!buffer.empty()) load_tracks_onto_GPU(buffer, starts, sizes, tck_count); - file.close(); GL::assert_context_is_current(); } void Tractogram::load_end_colours() { - // These data are now retained in memory - no need to re-scan track file - if (!colour_buffers.empty()) + // Colour buffers are reused if already built from endpoint tangents. + // If they were built from group colours, erase and rebuild from endpoint tangents. + if (!colour_buffers.empty() && !colour_buffers_from_groups) return; + if (colour_buffers_from_groups) { + erase_colour_data(); + colour_buffers_from_groups = false; + } // Make sure to set graphics context! // We're setting up vertex array objects @@ -636,8 +665,7 @@ void Tractogram::load_end_colours() { load_end_colours_onto_GPU(buffer); } assert(colour_buffers.size() == vertex_buffers.size()); - // Don't need this now that we've initialised the GPU buffers - endpoint_tangents.clear(); + // Keep endpoint_tangents in memory so group→ends switching can rebuild GL::assert_context_is_current(); } @@ -860,8 +888,9 @@ void Tractogram::erase_threshold_scalar_data() { } void Tractogram::set_color_type(const TrackColourType c) { - if ((color_type == TrackColourType::Ends && c == TrackColourType::ScalarFile) || - (color_type == TrackColourType::ScalarFile && c == TrackColourType::Ends)) + auto uses_colour_buf = [](TrackColourType t) { return t == TrackColourType::Ends || t == TrackColourType::Group; }; + if (uses_colour_buf(color_type) != uses_colour_buf(c) || + (color_type == TrackColourType::ScalarFile) != (c == TrackColourType::ScalarFile)) vao_dirty = true; color_type = c; } @@ -997,4 +1026,236 @@ void Tractogram::load_threshold_scalars_onto_GPU(std::vector &buffer, siz GL::assert_context_is_current(); } +bool Tractogram::is_trx() const { return DWI::Tractography::TRX::is_trx(filename); } + +const std::vector &Tractogram::trx_dps_fields() const { return cached_trx_dps_names; } +const std::vector &Tractogram::trx_dpv_fields() const { return cached_trx_dpv_names; } +const std::vector &Tractogram::trx_group_names() const { return cached_trx_group_names; } + +trx::TrxFile *Tractogram::get_cached_trx() { + if (!cached_trx) + cached_trx = DWI::Tractography::TRX::load_trx(filename); + return cached_trx.get(); +} + +void Tractogram::load_trx_scalar_field(const std::string &field_name, bool is_dpv) { + GL::Context::Grab context; + GL::assert_context_is_current(); + + erase_intensity_scalar_data(); + value_min = std::numeric_limits::infinity(); + value_max = -std::numeric_limits::infinity(); + + trx::TrxFile *trx = get_cached_trx(); + if (!trx || !trx->streamlines) + throw Exception("Failed to load TRX file: " + std::string(filename)); + + std::vector buffer; + size_t global_idx = 0; + + if (!is_dpv) { + // DPS: replicate per-streamline scalar to all vertices of that streamline + auto it = trx->data_per_streamline.find(field_name); + if (it == trx->data_per_streamline.end() || !it->second) + throw Exception("TRX file has no dps field named \"" + field_name + "\""); + const auto &mat = it->second->_matrix; + if (mat.cols() != 1) + throw Exception("TRX dps field \"" + field_name + "\" must have exactly 1 column for scalar display"); + if (mat.rows() != static_cast(trx->num_streamlines())) { + throw Exception("TRX dps field \"" + field_name + "\" length (" + str(mat.rows()) + + ") does not match streamline count (" + str(trx->num_streamlines()) + ")"); + } + + for (size_t buf_idx = 0; buf_idx != vertex_buffers.size(); ++buf_idx) { + size_t n = num_tracks_per_buffer[buf_idx]; + for (size_t ti = 0; ti < n; ++ti, ++global_idx) { + const float v = mat(static_cast(global_idx), 0); + const size_t len = static_cast(original_track_sizes[buf_idx][ti]); + for (int i = 0; i < track_padding; ++i) + buffer.push_back(v); + for (size_t i = 0; i < len; ++i) + buffer.push_back(v); + for (int i = 0; i < track_padding; ++i) + buffer.push_back(v); + if (std::isfinite(v)) { + value_max = std::max(value_max, v); + value_min = std::min(value_min, v); + } + } + load_intensity_scalars_onto_GPU(buffer, n); + } + } else { + // DPV: per-vertex scalar using streamline offsets + auto it = trx->data_per_vertex.find(field_name); + if (it == trx->data_per_vertex.end() || !it->second) + throw Exception("TRX file has no dpv field named \"" + field_name + "\""); + const auto &dpv = it->second->_data; + if (dpv.cols() != 1) + throw Exception("TRX dpv field \"" + field_name + "\" must have exactly 1 column for scalar display"); + if (dpv.rows() != static_cast(trx->num_vertices())) { + throw Exception("TRX dpv field \"" + field_name + "\" length (" + str(dpv.rows()) + + ") does not match vertex count (" + str(trx->num_vertices()) + ")"); + } + const auto &offsets = trx->streamlines->_offsets; + + for (size_t buf_idx = 0; buf_idx != vertex_buffers.size(); ++buf_idx) { + size_t n = num_tracks_per_buffer[buf_idx]; + for (size_t ti = 0; ti < n; ++ti, ++global_idx) { + const Eigen::Index si = static_cast(global_idx); + const Eigen::Index v0 = offsets(si, 0), v1 = offsets(si + 1, 0); + if (v1 <= v0) + throw Exception("Invalid streamline offsets encountered while loading TRX dpv field \"" + field_name + "\""); + const float front = dpv(v0, 0); + const float back = dpv(v1 - 1, 0); + for (int i = 0; i < track_padding; ++i) + buffer.push_back(front); + for (Eigen::Index vi = v0; vi < v1; ++vi) { + const float val = dpv(vi, 0); + buffer.push_back(val); + if (std::isfinite(val)) { + value_max = std::max(value_max, val); + value_min = std::min(value_min, val); + } + } + for (int i = 0; i < track_padding; ++i) + buffer.push_back(back); + } + load_intensity_scalars_onto_GPU(buffer, n); + } + } + + // Fall back to [0, 1] if all values were non-finite + if (!std::isfinite(value_min) || !std::isfinite(value_max) || value_min >= value_max) { + value_min = 0.0f; + value_max = 1.0f; + } + intensity_scalar_filename = field_name; + set_windowing(value_min, value_max); + if (!std::isfinite(greaterthan)) + greaterthan = value_max; + if (!std::isfinite(lessthan)) + lessthan = value_min; + GL::assert_context_is_current(); +} + +void Tractogram::init_group_states() { + static const Eigen::Vector3f PALETTE[] = { + {0.89f, 0.10f, 0.11f}, + {0.22f, 0.49f, 0.72f}, + {0.30f, 0.69f, 0.29f}, + {0.60f, 0.31f, 0.64f}, + {1.00f, 0.50f, 0.00f}, + {1.00f, 1.00f, 0.20f}, + {0.65f, 0.34f, 0.16f}, + {0.97f, 0.51f, 0.75f}, + {0.60f, 0.60f, 0.60f}, + {0.00f, 0.75f, 0.75f}, + {0.74f, 0.74f, 0.00f}, + {0.50f, 0.00f, 0.50f}, + }; + static constexpr size_t PALETTE_SIZE = 12; + + group_states.clear(); + group_order.clear(); + + trx::TrxFile *trx = get_cached_trx(); + if (!trx) + return; + + size_t g = 0; + for (const auto &kv : trx->groups) { + GroupState gs; + gs.visible = true; + gs.color = PALETTE[g % PALETTE_SIZE]; + gs.count = kv.second ? static_cast(kv.second->_matrix.rows()) : 0; + group_states[kv.first] = gs; + group_order.push_back(kv.first); + ++g; + } +} + +void Tractogram::reload_group_colours() { + GL::Context::Grab context; + GL::assert_context_is_current(); + + erase_colour_data(); + colour_buffers_from_groups = true; + + static const Eigen::Vector3f HIDDEN(-1.0f, -1.0f, -1.0f); + static const Eigen::Vector3f UNGROUPED(0.3f, 0.3f, 0.3f); + + trx::TrxFile *trx = get_cached_trx(); + const size_t ns = trx ? trx->num_streamlines() : 0; + std::vector scolours(ns, HIDDEN); + + if (trx) { + // Ungrouped streamlines: those not in any group at all + if (show_ungrouped) { + std::vector in_any_group(ns, false); + for (const auto &kv : trx->groups) { + if (!kv.second) + continue; + const auto &mat = kv.second->_matrix; + for (Eigen::Index i = 0; i < mat.rows(); ++i) { + const auto idx = static_cast(mat(i, 0)); + if (idx < ns) + in_any_group[idx] = true; + } + } + for (size_t i = 0; i < ns; ++i) + if (!in_any_group[i]) + scolours[i] = UNGROUPED; + } + + // Assign group colours; only visible groups participate + std::vector claimed(ns, false); + auto apply = [&](const std::string &name) { + const auto git = group_states.find(name); + if (git == group_states.end() || !git->second.visible) + return; + const auto tit = trx->groups.find(name); + if (tit == trx->groups.end() || !tit->second) + return; + const Eigen::Vector3f &c = git->second.color; + const auto &mat = tit->second->_matrix; + for (Eigen::Index i = 0; i < mat.rows(); ++i) { + const auto idx = static_cast(mat(i, 0)); + if (idx < ns && !claimed[idx]) { + scolours[idx] = c; + claimed[idx] = true; + } + } + }; + + if (group_multi_policy == GroupMultiPolicy::FirstMatch) { + for (const auto &name : group_order) + apply(name); + } else { + for (auto it = group_order.rbegin(); it != group_order.rend(); ++it) + apply(*it); + } + } + + size_t total = 0; + for (size_t buf_idx = 0, N = vertex_buffers.size(); buf_idx < N; ++buf_idx) { + const size_t n = num_tracks_per_buffer[buf_idx]; + std::vector buffer; + for (size_t ti = 0; ti < n; ++ti) { + const Eigen::Vector3f &c = (total < scolours.size()) ? scolours[total] : HIDDEN; + ++total; + const size_t len = static_cast(original_track_sizes[buf_idx][ti]); + for (size_t i = 0; i < len + 2 * static_cast(track_padding); ++i) + buffer.push_back(c); + } + load_end_colours_onto_GPU(buffer); + } + GL::assert_context_is_current(); +} + +void Tractogram::load_trx_group_colours() { + if (group_states.empty()) + init_group_states(); + reload_group_colours(); +} + } // namespace MR::GUI::MRView::Tool diff --git a/cpp/gui/mrview/tool/tractography/tractogram.h b/cpp/gui/mrview/tool/tractography/tractogram.h index 4fa7ded2cb..2cd6b96468 100644 --- a/cpp/gui/mrview/tool/tractography/tractogram.h +++ b/cpp/gui/mrview/tool/tractography/tractogram.h @@ -16,11 +16,15 @@ #pragma once +#include +#include +#include #include #include "dwi/tractography/properties.h" #include "mrview/displayable.h" #include "mrview/tool/tractography/tractography.h" +#include "trx/trx.h" namespace MR::GUI { class Projection; @@ -55,6 +59,26 @@ class Tractogram : public Displayable { void erase_intensity_scalar_data(); void erase_threshold_scalar_data(); + // TRX-specific helpers + bool is_trx() const; + const std::vector &trx_dps_fields() const; + const std::vector &trx_dpv_fields() const; + const std::vector &trx_group_names() const; + void load_trx_scalar_field(const std::string &field_name, bool is_dpv); + void load_trx_group_colours(); + void init_group_states(); + void reload_group_colours(); + + struct GroupState { + bool visible = true; + Eigen::Vector3f color{0.5f, 0.5f, 0.5f}; + size_t count = 0; + }; + std::map group_states; + std::vector group_order; + bool show_ungrouped; + GroupMultiPolicy group_multi_policy; + void set_color_type(const TrackColourType); void set_threshold_type(const TrackThresholdType); void set_geometry_type(const TrackGeometryType); @@ -146,6 +170,17 @@ class Tractogram : public Displayable { std::vector element_buffers; std::vector element_counts; + // TRX metadata and file cached at load time to avoid re-loading on every + // field change (especially important for float16 files which require a + // full decode pass and print a warning on every load). + bool colour_buffers_from_groups; + std::vector cached_trx_dps_names; + std::vector cached_trx_dpv_names; + std::vector cached_trx_group_names; + std::unique_ptr> cached_trx; + + trx::TrxFile *get_cached_trx(); + GLint sample_stride; bool vao_dirty; diff --git a/cpp/gui/mrview/tool/tractography/tractogram_enums.h b/cpp/gui/mrview/tool/tractography/tractogram_enums.h index 3a8e0af694..598f821d05 100644 --- a/cpp/gui/mrview/tool/tractography/tractogram_enums.h +++ b/cpp/gui/mrview/tool/tractography/tractogram_enums.h @@ -17,7 +17,8 @@ #pragma once namespace MR::GUI::MRView::Tool { -enum class TrackColourType { Direction, Ends, Manual, ScalarFile }; +enum class TrackColourType { Direction, Ends, Manual, ScalarFile, Group }; enum class TrackGeometryType { Pseudotubes, Lines, Points }; enum class TrackThresholdType { None, UseColourFile, SeparateFile }; +enum class GroupMultiPolicy { FirstMatch, LastMatch }; } // namespace MR::GUI::MRView::Tool diff --git a/cpp/gui/mrview/tool/tractography/tractography.cpp b/cpp/gui/mrview/tool/tractography/tractography.cpp index b1c5968a8c..6e81a9b232 100644 --- a/cpp/gui/mrview/tool/tractography/tractography.cpp +++ b/cpp/gui/mrview/tool/tractography/tractography.cpp @@ -31,6 +31,32 @@ namespace MR::GUI::MRView::Tool { const std::vector tractogram_geometry_types = {"pseudotubes", "lines", "points"}; +namespace { +constexpr int colour_idx_direction = 0; +constexpr int colour_idx_ends = 1; +constexpr int colour_idx_random = 2; +constexpr int colour_idx_manual = 3; +constexpr int colour_idx_scalar = 4; +constexpr int colour_idx_group = 5; + +inline int colour_type_to_index(const TrackColourType type) { + switch (type) { + case TrackColourType::Direction: + return colour_idx_direction; + case TrackColourType::Ends: + return colour_idx_ends; + case TrackColourType::Manual: + return colour_idx_manual; + case TrackColourType::ScalarFile: + return colour_idx_scalar; + case TrackColourType::Group: + return colour_idx_group; + } + assert(0); + return colour_idx_direction; +} +} // namespace + TrackGeometryType geometry_index2type(const int idx) { switch (idx) { case 0: @@ -90,6 +116,7 @@ Tractography::Tractography(Dock *parent) not_3D(true), line_opacity(1.0), scalar_file_options(nullptr), + group_options(nullptr), lighting_dock(nullptr) { float voxel_size; @@ -170,6 +197,7 @@ Tractography::Tractography(Dock *parent) colour_combobox->addItem("Random"); colour_combobox->addItem("Manual"); colour_combobox->addItem("File"); + colour_combobox->addItem("TRX Groups"); colour_combobox->setEnabled(false); connect(colour_combobox, SIGNAL(activated(int)), this, SLOT(colour_mode_selection_slot(int))); hlayout->addWidget(colour_combobox); @@ -216,6 +244,9 @@ Tractography::Tractography(Dock *parent) scalar_file_options = new TrackScalarFileOptions(this); main_box->addWidget(scalar_file_options); + group_options = new TrackGroupOptions(this); + main_box->addWidget(group_options); + QGroupBox *general_groupbox = new QGroupBox("General options"); GridLayout *general_opt_grid = new GridLayout; general_opt_grid->setContentsMargins(0, 0, 0, 0); @@ -349,7 +380,7 @@ size_t Tractography::visible_number_colourbars() { void Tractography::tractogram_open_slot() { std::vector list = - Dialog::File::get_files(this, "Select tractograms to open", "Tractograms (*.tck)", ¤t_folder); + Dialog::File::get_files(this, "Select tractograms to open", "Tractograms (*.tck *.trx)", ¤t_folder); add_tractogram(list); } @@ -394,6 +425,8 @@ void Tractography::tractogram_close_slot() { } scalar_file_options->set_tractogram(nullptr); scalar_file_options->update_UI(); + group_options->set_tractogram(nullptr); + group_options->update_UI(); window().updateGL(); } @@ -476,7 +509,7 @@ void Tractography::colour_track_by_direction_slot() { tractogram->set_threshold_type(TrackThresholdType::None); } colour_combobox->blockSignals(true); - colour_combobox->setCurrentIndex(0); + colour_combobox->setCurrentIndex(colour_idx_direction); colour_combobox->clearError(); colour_combobox->blockSignals(false); colour_button->setEnabled(false); @@ -494,7 +527,7 @@ void Tractography::colour_track_by_ends_slot() { tractogram->set_threshold_type(TrackThresholdType::None); } colour_combobox->blockSignals(true); - colour_combobox->setCurrentIndex(1); + colour_combobox->setCurrentIndex(colour_idx_ends); colour_combobox->clearError(); colour_combobox->blockSignals(false); colour_button->setEnabled(false); @@ -522,7 +555,7 @@ void Tractography::randomise_track_colour_slot() { colour_button->setColor(c); } colour_combobox->blockSignals(true); - colour_combobox->setCurrentIndex(2); + colour_combobox->setCurrentIndex(colour_idx_random); colour_combobox->clearError(); colour_combobox->blockSignals(false); colour_button->setEnabled(true); @@ -543,7 +576,7 @@ void Tractography::set_track_colour_slot() { tractogram->set_threshold_type(TrackThresholdType::None); } colour_combobox->blockSignals(true); - colour_combobox->setCurrentIndex(3); + colour_combobox->setCurrentIndex(colour_idx_manual); colour_combobox->clearError(); colour_combobox->blockSignals(false); colour_button->setEnabled(true); @@ -568,30 +601,28 @@ void Tractography::colour_by_scalar_file_slot() { Tractogram *tractogram = tractogram_list_model->get_tractogram(indices[0]); scalar_file_options->set_tractogram(tractogram); if (tractogram->intensity_scalar_filename.empty()) { - if (!scalar_file_options->open_intensity_track_scalar_file_slot()) { + auto revert_combobox = [&]() { colour_combobox->blockSignals(true); - switch (tractogram->get_color_type()) { - case TrackColourType::Direction: - colour_combobox->setCurrentIndex(0); - break; - case TrackColourType::Ends: - colour_combobox->setCurrentIndex(1); - break; - case TrackColourType::Manual: - colour_combobox->setCurrentIndex(3); - break; - case TrackColourType::ScalarFile: - colour_combobox->setCurrentIndex(4); - break; - } + colour_combobox->setCurrentIndex(colour_type_to_index(tractogram->get_color_type())); colour_combobox->clearError(); colour_combobox->blockSignals(false); + }; + if (tractogram->is_trx()) { + // No scalar loaded yet — revert combobox without crashing. The "TRX + // field…" button in the scalar file options panel is always visible for + // TRX files, so the user can pick a field from there directly. + revert_combobox(); + update_scalar_options(); + return; + } + if (!scalar_file_options->open_intensity_track_scalar_file_slot()) { + revert_combobox(); return; } } tractogram->set_color_type(TrackColourType::ScalarFile); colour_combobox->blockSignals(true); - colour_combobox->setCurrentIndex(4); + colour_combobox->setCurrentIndex(colour_idx_scalar); colour_combobox->clearError(); colour_combobox->blockSignals(false); colour_button->setEnabled(false); @@ -601,22 +632,23 @@ void Tractography::colour_by_scalar_file_slot() { void Tractography::colour_mode_selection_slot(int) { switch (colour_combobox->currentIndex()) { - case 0: + case colour_idx_direction: colour_track_by_direction_slot(); break; - case 1: + case colour_idx_ends: colour_track_by_ends_slot(); break; - case 2: + case colour_idx_random: randomise_track_colour_slot(); break; - case 3: + case colour_idx_manual: set_track_colour_slot(); break; - case 4: + case colour_idx_scalar: colour_by_scalar_file_slot(); break; - case 5: + case colour_idx_group: + colour_track_by_trx_groups_slot(); break; default: assert(0); @@ -633,7 +665,7 @@ void Tractography::colour_button_slot() { for (int i = 0; i < indices.size(); ++i) tractogram_list_model->get_tractogram(indices[i])->set_colour(color); colour_combobox->blockSignals(true); - colour_combobox->setCurrentIndex(3); // In case it was on random + colour_combobox->setCurrentIndex(colour_idx_manual); // In case it was on random colour_combobox->clearError(); colour_combobox->blockSignals(false); window().updateGL(); @@ -686,25 +718,11 @@ void Tractography::selection_changed_slot(const QItemSelection &, const QItemSel } if (color_type_consistent) { colour_combobox->blockSignals(true); - switch (color_type) { - case TrackColourType::Direction: - colour_combobox->setCurrentIndex(0); - colour_button->setEnabled(false); - break; - case TrackColourType::Ends: - colour_combobox->setCurrentIndex(1); - colour_button->setEnabled(false); - break; - case TrackColourType::Manual: - colour_combobox->setCurrentIndex(3); - colour_button->setEnabled(true); + colour_combobox->setCurrentIndex(colour_type_to_index(color_type)); + const bool manual_colour = (color_type == TrackColourType::Manual); + colour_button->setEnabled(manual_colour); + if (manual_colour) colour_button->setColor(color); - break; - case TrackColourType::ScalarFile: - colour_combobox->setCurrentIndex(4); - colour_button->setEnabled(false); - break; - } colour_combobox->clearError(); colour_combobox->blockSignals(false); } else { @@ -737,11 +755,11 @@ void Tractography::selection_changed_slot(const QItemSelection &, const QItemSel void Tractography::update_scalar_options() { QModelIndexList indices = tractogram_list_view->selectionModel()->selectedIndexes(); - if (indices.size() == 1) - scalar_file_options->set_tractogram(tractogram_list_model->get_tractogram(indices[0])); - else - scalar_file_options->set_tractogram(nullptr); + Tractogram *t = (indices.size() == 1) ? tractogram_list_model->get_tractogram(indices[0]) : nullptr; + scalar_file_options->set_tractogram(t); scalar_file_options->update_UI(); + group_options->set_tractogram(t); + group_options->update_UI(); } void Tractography::update_geometry_type_gui() { @@ -810,6 +828,12 @@ void Tractography::add_commandline_options(MR::App::OptionList &options) { "Load the specified tractography scalar file.").allow_multiple() + Argument("tsf").type_file_in() + + Option("tractography.trx_scalar", + "Load an embedded TRX scalar field for the selected tractogram. " + "Accepts either , dps:, or dpv:. " + "If exists in both dps and dpv, use the explicit prefix.").allow_multiple() + + Argument("field").type_text() + + Option("tractography.tsf_range", "Set range for the tractography scalar file." " Requires -tractography.tsf_load already provided.").allow_multiple() @@ -862,7 +886,7 @@ bool Tractography::process_commandline_option(const MR::App::ParsedOption &opt) scalar_file_options->open_intensity_track_scalar_file_slot(std::string(opt[0])); // Set the GUI to use the file for visualisation - colour_combobox->setCurrentIndex(4); // Set combobox to "File" + colour_combobox->setCurrentIndex(colour_idx_scalar); // Set combobox to "File" } } } catch (Exception &E) { @@ -872,6 +896,27 @@ bool Tractography::process_commandline_option(const MR::App::ParsedOption &opt) return true; } + if (opt.opt->is("tractography.trx_scalar")) { + try { + if (process_commandline_option_tsf_check_tracto_loaded()) { + QModelIndexList indices = tractogram_list_view->selectionModel()->selectedIndexes(); + if (indices.size() != 1) + throw Exception("-tractography.trx_scalar option requires one tractogram to be selected"); + + Tractogram *tractogram = tractogram_list_model->get_tractogram(indices[0]); + if (!tractogram || !tractogram->is_trx()) + throw Exception("-tractography.trx_scalar requires the selected tractogram to be a TRX file"); + + scalar_file_options->set_tractogram(tractogram); + scalar_file_options->open_trx_scalar_field_by_name(std::string(opt[0])); + colour_combobox->setCurrentIndex(colour_idx_scalar); // Set combobox to "File" + } + } catch (Exception &E) { + E.display(); + } + return true; + } + if (opt.opt->is("tractography.tsf_range")) { try { // Set the tsf visualisation range @@ -961,7 +1006,7 @@ bool Tractography::process_commandline_option(const MR::App::ParsedOption &opt) tractogram->set_colour(colour); // update_color_type_gui - colour_combobox->setCurrentIndex(3); + colour_combobox->setCurrentIndex(colour_idx_manual); colour_button->setEnabled(true); colour_button->setColor(colour); @@ -1060,4 +1105,40 @@ bool Tractography::process_commandline_option_tsf_option(const MR::App::ParsedOp } return false; } + +void Tractography::colour_track_by_trx_groups_slot() { + QModelIndexList indices = tractogram_list_view->selectionModel()->selectedIndexes(); + bool any_trx = false; + for (int i = 0; i < indices.size(); ++i) { + Tractogram *tractogram = tractogram_list_model->get_tractogram(indices[i]); + if (!tractogram->is_trx()) { + QMessageBox::warning(QApplication::activeWindow(), + tr("TRX Groups"), + tr("TRX group colouring is only available for TRX files."), + QMessageBox::Ok, + QMessageBox::Ok); + continue; + } + if (tractogram->trx_group_names().empty()) { + QMessageBox::information( + QApplication::activeWindow(), tr("TRX Groups"), tr("This TRX file has no groups."), QMessageBox::Ok); + continue; + } + tractogram->load_trx_group_colours(); + tractogram->set_color_type(TrackColourType::Group); + if (tractogram->get_threshold_type() == TrackThresholdType::UseColourFile) + tractogram->set_threshold_type(TrackThresholdType::None); + any_trx = true; + } + if (any_trx) { + colour_combobox->blockSignals(true); + colour_combobox->setCurrentIndex(colour_idx_group); + colour_combobox->clearError(); + colour_combobox->blockSignals(false); + colour_button->setEnabled(false); + update_scalar_options(); + window().updateGL(); + } +} + } // namespace MR::GUI::MRView::Tool diff --git a/cpp/gui/mrview/tool/tractography/tractography.h b/cpp/gui/mrview/tool/tractography/tractography.h index 2a7071a61a..9af98f78fe 100644 --- a/cpp/gui/mrview/tool/tractography/tractography.h +++ b/cpp/gui/mrview/tool/tractography/tractography.h @@ -20,6 +20,7 @@ #include "mrview/adjust_button.h" #include "mrview/combo_box_error.h" #include "mrview/tool/base.h" +#include "mrview/tool/tractography/track_group_options.h" #include "mrview/tool/tractography/track_scalar_file.h" #include "projection.h" @@ -82,6 +83,7 @@ private slots: void randomise_track_colour_slot(); void set_track_colour_slot(); void colour_by_scalar_file_slot(); + void colour_track_by_trx_groups_slot(); void colour_mode_selection_slot(int); void colour_button_slot(); void geom_type_selection_slot(int); @@ -100,6 +102,7 @@ private slots: QSlider *thickness_slider; TrackScalarFileOptions *scalar_file_options; + TrackGroupOptions *group_options; LightingDock *lighting_dock; QGroupBox *slab_group_box; diff --git a/docs/reference/commands/mrview.rst b/docs/reference/commands/mrview.rst index 4c51a5a6a5..643a29c620 100644 --- a/docs/reference/commands/mrview.rst +++ b/docs/reference/commands/mrview.rst @@ -146,6 +146,8 @@ Tractography tool options - **-tractography.tsf_load tsf** *(multiple uses permitted)* Load the specified tractography scalar file. +- **-tractography.trx_scalar field** *(multiple uses permitted)* Load an embedded TRX scalar field for the selected tractogram. Accepts either ``name``, ``dps:name``, or ``dpv:name``. If ``name`` exists in both dps and dpv, use the explicit prefix. + - **-tractography.tsf_range RangeMin,RangeMax** *(multiple uses permitted)* Set range for the tractography scalar file. Requires -tractography.tsf_load already provided. - **-tractography.tsf_thresh ThresholdMin,ThresholdMax** *(multiple uses permitted)* Set thresholds for the tractography scalar file. Requires -tractography.tsf_load already provided. 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/CMakeLists.txt b/testing/CMakeLists.txt index 5d4de33075..e839155187 100644 --- a/testing/CMakeLists.txt +++ b/testing/CMakeLists.txt @@ -1,5 +1,5 @@ if(NOT DEFINED ENV{MRTRIX_BINARIES_DATA_DIR}) - set(mrtrix_binaries_data_url "https://github.com/mrtrix3/test_data.git") + set(mrtrix_binaries_data_url "https://github.com/mattcieslak/test_data.git") else() set(mrtrix_binaries_data_url $ENV{MRTRIX_BINARIES_DATA_DIR}) message(STATUS "Using binaries data from $ENV{MRTRIX_BINARIES_DATA_DIR}") @@ -16,7 +16,7 @@ include(ExternalProject) ExternalProject_Add(BinariesTestData PREFIX ${CMAKE_CURRENT_BINARY_DIR}/binaries_data GIT_REPOSITORY ${mrtrix_binaries_data_url} - GIT_TAG 3d30c7fff58f9ca0bae5ccfe5565f35aa76b6dc6 + GIT_TAG e3a85f94bf79b0556d940c9ffde3899eb86d7dd8 GIT_PROGRESS TRUE CONFIGURE_COMMAND "" BUILD_COMMAND "" diff --git a/testing/binaries/CMakeLists.txt b/testing/binaries/CMakeLists.txt index 5b79e20b14..a277539b7d 100644 --- a/testing/binaries/CMakeLists.txt +++ b/testing/binaries/CMakeLists.txt @@ -36,6 +36,11 @@ add_bash_binary_test(5ttedit/twotissues) add_bash_binary_test(amp2sh/default) +add_bash_binary_test(connectome2tck/trx_all_voxels_parity) +add_bash_binary_test(connectome2tck/trx_default) +add_bash_binary_test(connectome2tck/trx_forward_search) +add_bash_binary_test(connectome2tck/trx_group_prefix) + add_bash_binary_test(connectomeedit/lower_triangular_from_asymmetric) add_bash_binary_test(connectomeedit/lower_triangular_from_lower_triangular) add_bash_binary_test(connectomeedit/lower_triangular_from_symmetric) @@ -119,6 +124,7 @@ add_bash_binary_test(fixel2sh/default) add_bash_binary_test(fixel2sh/lmax) add_bash_binary_test(fixel2tsf/default) +add_bash_binary_test(fixel2tsf/trx_dpv) add_bash_binary_test(fixel2voxel/absmax) add_bash_binary_test(fixel2voxel/complexity) @@ -139,6 +145,7 @@ add_bash_binary_test(fixelcfestats/legacy) add_bash_binary_test(fixelcfestats/masked) add_bash_binary_test(fixelconnectivity/masked) +add_bash_binary_test(fixelconnectivity/trx_default) add_bash_binary_test(fixelconnectivity/weighted) add_bash_binary_test(fixelcorrespondence/default) @@ -407,6 +414,11 @@ add_bash_binary_test(tckconvert/rib_write) add_bash_binary_test(tckconvert/scanner2voxel) add_bash_binary_test(tckconvert/text_read_range) add_bash_binary_test(tckconvert/text_write) +add_bash_binary_test(tckconvert/trx_read) +add_bash_binary_test(tckconvert/trx_uncompressed_read) +add_bash_binary_test(tckconvert/trx_write_dps_float16) +add_bash_binary_test(tckconvert/trx_write_dps_float32) +add_bash_binary_test(tckconvert/trx_write_dps_float64) add_bash_binary_test(tckconvert/vtk_read_binary) add_bash_binary_test(tckconvert/vtk_read_empty) add_bash_binary_test(tckconvert/vtk_write_ascii) @@ -416,6 +428,8 @@ add_bash_binary_test(tckedit/include_exclude_inverse) add_bash_binary_test(tckedit/include_mask) add_bash_binary_test(tckedit/length) add_bash_binary_test(tckedit/mask) +add_bash_binary_test(tckedit/trx_length) +add_bash_binary_test(tckedit/trx_preserves_metadata) add_bash_binary_test(tckgen/act_terminations) add_bash_binary_test(tckgen/algorithm_ifod2) @@ -432,20 +446,28 @@ add_bash_binary_test(tckgen/seed_image) add_bash_binary_test(tckgen/seed_random_per_voxel) add_bash_binary_test(tckgen/seed_rejection) add_bash_binary_test(tckgen/seed_sphere) +add_bash_binary_test(tckgen/trx_float16) +add_bash_binary_test(tckgen/trx_output) add_bash_binary_test(tckglobal/default) add_bash_binary_test(tckglobal/masked) add_bash_binary_test(tckglobal/multitissue) +add_bash_binary_test(tckinfo/trx_metadata) + add_bash_binary_test(tckmap/dec) add_bash_binary_test(tckmap/default_template) add_bash_binary_test(tckmap/default_vox) add_bash_binary_test(tckmap/tod) +add_bash_binary_test(tckmap/trx_dps_weights) +add_bash_binary_test(tckmap/trx_length_contrast) +add_bash_binary_test(tckmap/trx_tdi) add_bash_binary_test(tckresample/downsample) add_bash_binary_test(tckresample/endpoints) add_bash_binary_test(tckresample/num_points) add_bash_binary_test(tckresample/step_size) +add_bash_binary_test(tckresample/trx_downsample) add_bash_binary_test(tckresample/upsample) add_bash_binary_test(tcksample/precise) @@ -456,11 +478,20 @@ add_bash_binary_test(tcksample/statmin) add_bash_binary_test(tcksample/statnone_text) add_bash_binary_test(tcksample/statnone_tsf) add_bash_binary_test(tcksample/tdifraction) +add_bash_binary_test(tcksample/trx_dps_mean) +add_bash_binary_test(tcksample/trx_dpv) add_bash_binary_test(tcksift/default) +add_bash_binary_test(tcksift/trx_subset) add_bash_binary_test(tcksift2/default) +add_bash_binary_test(tcksift2/trx_dps) +add_bash_binary_test(tckstats/trx_dps) +add_bash_binary_test(tckstats/trx_stats) + +add_bash_binary_test(tcktransform/trx_geometry) +add_bash_binary_test(tcktransform/trx_preserves_metadata) add_bash_binary_test(tcktransform/unitwarp) add_bash_binary_test(tensor2metric/adc) @@ -483,14 +514,30 @@ add_bash_binary_test(transformconvert/itk_ants) add_bash_binary_test(transformconvert/itk_ants_zero) add_bash_binary_test(transformconvert/itk_slicer) +add_bash_binary_test(trx2connectome/all_voxels_parity) +add_bash_binary_test(trx2connectome/default) +add_bash_binary_test(trx2connectome/forward_search) +add_bash_binary_test(trx2connectome/group_prefix) + +add_bash_binary_test(trxlabel/default) +add_bash_binary_test(trxlabel/multiple_atlases) + add_bash_binary_test(tsfdivide/default) +add_bash_binary_test(tsfdivide/trx_dpv) + +add_bash_binary_test(tsfinfo/trx_dpv) add_bash_binary_test(tsfmult/default) +add_bash_binary_test(tsfmult/trx_dpv) add_bash_binary_test(tsfsmooth/default) +add_bash_binary_test(tsfsmooth/trx_dpv) add_bash_binary_test(tsfthreshold/default) add_bash_binary_test(tsfthreshold/invert) +add_bash_binary_test(tsfthreshold/trx_dpv) + +add_bash_binary_test(tsfvalidate/trx_dpv) add_bash_binary_test(vectorstats/0) add_bash_binary_test(vectorstats/1) diff --git a/testing/binaries/tests/connectome2tck/trx_all_voxels_parity b/testing/binaries/tests/connectome2tck/trx_all_voxels_parity new file mode 100644 index 0000000000..eefb61c979 --- /dev/null +++ b/testing/binaries/tests/connectome2tck/trx_all_voxels_parity @@ -0,0 +1,15 @@ +#!/bin/bash +# Regression: connectome2tck in TRX-group mode ("-" assignments) should match +# the standard text-assignments workflow for -assignment_all_voxels. +tck2connectome SIFT_phantom/tracks.tck SIFT_phantom/parc.mif tmp_allvox_matrix.csv \ + -assignment_all_voxels -out_assignments tmp_allvox_assignments.txt -force + +connectome2tck SIFT_phantom/tracks.tck tmp_allvox_assignments.txt tmp_tck_allvox \ + -nodes 1,2 -exclusive -files single -force + +trxlabel SIFT_phantom/tracks.trx tmp_labeled_allvox.trx \ + -nodes SIFT_phantom/parc.mif -assignment_all_voxels -force +connectome2tck tmp_labeled_allvox.trx - tmp_trx_allvox \ + -nodes 1,2 -exclusive -files single -force + +testing_diff_tck tmp_tck_allvox.tck tmp_trx_allvox.tck -unordered diff --git a/testing/binaries/tests/connectome2tck/trx_default b/testing/binaries/tests/connectome2tck/trx_default new file mode 100755 index 0000000000..accb7b5754 --- /dev/null +++ b/testing/binaries/tests/connectome2tck/trx_default @@ -0,0 +1,25 @@ +#!/bin/bash +# Verify that connectome2tck with TRX input (assignments_in = "-") produces the +# same edge TCK as the standard TCK + text-file assignments workflow. +# +# TCK path: tck2connectome -out_assignments → connectome2tck with text file +# TRX path: trxlabel → connectome2tck with "-" (derive from groups) +# +# Both use the default radial-search assignment mechanism. The SIFT phantom has +# two active edges (1-2 and 3-4); we extract edge 1-2 as a single file and +# compare streamline geometry with -unordered (order may differ between paths). + +# TCK path: generate assignments file then extract edge 1-2 +tck2connectome SIFT_phantom/tracks.tck SIFT_phantom/parc.mif tmp_matrix.csv \ + -out_assignments tmp_asgn.csv -force +connectome2tck SIFT_phantom/tracks.tck tmp_asgn.csv tmp_tck_edge12 \ + -nodes 1,2 -exclusive -files single -force + +# TRX path: label the TRX then extract using "-" to skip the assignments file +trxlabel SIFT_phantom/tracks.trx tmp_labeled.trx \ + -nodes SIFT_phantom/parc.mif -force +connectome2tck tmp_labeled.trx - tmp_trx_edge12 \ + -nodes 1,2 -exclusive -files single -force + +# Edge 1-2 should contain identical streamlines regardless of path +testing_diff_tck tmp_tck_edge12.tck tmp_trx_edge12.tck -unordered diff --git a/testing/binaries/tests/connectome2tck/trx_forward_search b/testing/binaries/tests/connectome2tck/trx_forward_search new file mode 100755 index 0000000000..55f286544a --- /dev/null +++ b/testing/binaries/tests/connectome2tck/trx_forward_search @@ -0,0 +1,24 @@ +#!/bin/bash +# Verify that connectome2tck TRX mode works with alternative assignment mechanisms. +# Mirrors the tck2connectome/assignment_forward_search test. +# +# Both tck2connectome and trxlabel accept the same -assignment_* options; the +# forward-search and radial-search methods produce identical results on the SIFT +# phantom (simple geometry), so the output should still match tck2connectome/out.csv +# and the extracted edge TCKs should match between paths. + +# TCK path with forward search +tck2connectome SIFT_phantom/tracks.tck SIFT_phantom/parc.mif tmp_matrix.csv \ + -out_assignments tmp_asgn.csv \ + -assignment_forward_search 5 -force +connectome2tck SIFT_phantom/tracks.tck tmp_asgn.csv tmp_tck_edge12 \ + -nodes 1,2 -exclusive -files single -force + +# TRX path with the same forward search +trxlabel SIFT_phantom/tracks.trx tmp_labeled.trx \ + -nodes SIFT_phantom/parc.mif \ + -assignment_forward_search 5 -force +connectome2tck tmp_labeled.trx - tmp_trx_edge12 \ + -nodes 1,2 -exclusive -files single -force + +testing_diff_tck tmp_tck_edge12.tck tmp_trx_edge12.tck -unordered diff --git a/testing/binaries/tests/connectome2tck/trx_group_prefix b/testing/binaries/tests/connectome2tck/trx_group_prefix new file mode 100755 index 0000000000..eddd83bba9 --- /dev/null +++ b/testing/binaries/tests/connectome2tck/trx_group_prefix @@ -0,0 +1,27 @@ +#!/bin/bash +# Verify that -group_prefix correctly filters to one atlas when a TRX file has +# groups from multiple atlases, and that the extracted streamlines match the +# single-atlas result. +# +# The TRX is labeled twice with different prefixes (dk, aal) but the same +# parcellation image, so both atlases assign identical groups. Extracting with +# -group_prefix dk should produce the same edge TCK as extracting from an +# unlabeled (single-atlas) TRX. + +# Reference: single-atlas label, no prefix +trxlabel SIFT_phantom/tracks.trx tmp_single.trx \ + -nodes SIFT_phantom/parc.mif -force +connectome2tck tmp_single.trx - tmp_ref_edge12 \ + -nodes 1,2 -exclusive -files single -force + +# Multi-atlas label: same image twice, different prefixes +trxlabel SIFT_phantom/tracks.trx tmp_multi.trx \ + -nodes SIFT_phantom/parc.mif -nodes SIFT_phantom/parc.mif \ + -prefix dk -prefix aal -force + +# Extract using only the dk groups; result should match the single-atlas reference +connectome2tck tmp_multi.trx - tmp_trx_edge12 \ + -nodes 1,2 -exclusive -files single \ + -group_prefix dk -force + +testing_diff_tck tmp_ref_edge12.tck tmp_trx_edge12.tck -unordered diff --git a/testing/binaries/tests/fixel2tsf/trx_dpv b/testing/binaries/tests/fixel2tsf/trx_dpv new file mode 100755 index 0000000000..cc3dbba770 --- /dev/null +++ b/testing/binaries/tests/fixel2tsf/trx_dpv @@ -0,0 +1,6 @@ +#!/bin/bash +# Verify that fixel2tsf embeds per-vertex fixel values as a dpv field directly +# in the input TRX file when a bare field name is given as the output argument. +cp tracks.trx tmp_fixel_trx.trx +fixel2tsf fixel_image/afd.mif tmp_fixel_trx.trx afd +tckinfo tmp_fixel_trx.trx | grep -q "afd:" diff --git a/testing/binaries/tests/fixelconnectivity/trx_default b/testing/binaries/tests/fixelconnectivity/trx_default new file mode 100755 index 0000000000..d982f78182 --- /dev/null +++ b/testing/binaries/tests/fixelconnectivity/trx_default @@ -0,0 +1,12 @@ +#!/bin/bash +# Verify that fixelconnectivity produces the same output with TRX input as with TCK input. +tckconvert SIFT_phantom/tracks.tck tmp_tracks.trx -force +fixelconnectivity SIFT_phantom/fixels/ tmp_tracks.trx tmp/ \ +-count tmp-count.mif \ +-extent tmp-extent.mif \ +-force +testing_diff_image tmp/index.mif fixelconnectivity/default/index.mif +testing_diff_image tmp/fixels.mif fixelconnectivity/default/fixels.mif +testing_diff_image tmp/values.mif fixelconnectivity/default/values.mif +testing_diff_image tmp-count.mif fixelconnectivity/default_count.mif +testing_diff_image tmp-extent.mif fixelconnectivity/default_extent.mif diff --git a/testing/binaries/tests/tckconvert/trx_read b/testing/binaries/tests/tckconvert/trx_read new file mode 100644 index 0000000000..e87be39939 --- /dev/null +++ b/testing/binaries/tests/tckconvert/trx_read @@ -0,0 +1,3 @@ +#!/bin/bash +# convert TRX -> tck: +tckconvert tckconvert/gs.trx tmp.tck -force && testing_diff_tck tmp.tck tckconvert/gs.tck diff --git a/testing/binaries/tests/tckconvert/trx_uncompressed_read b/testing/binaries/tests/tckconvert/trx_uncompressed_read new file mode 100644 index 0000000000..7523541c57 --- /dev/null +++ b/testing/binaries/tests/tckconvert/trx_uncompressed_read @@ -0,0 +1,3 @@ +#!/bin/bash +# convert TRX (uncompressed directory) -> tck: +tckconvert tckconvert/gs_fldr.trx tmp.tck -force && testing_diff_tck tmp.tck tckconvert/gs.tck 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..0cc4638116 --- /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 tmp_dps.txt: +for n in {0..12} +do + echo "${n}.0" >> tmp_dps.txt +done + +tckconvert tckconvert/gs.tck tmp_dps_float16.trx -trxdirectory -dps dps float16 tmp_dps.txt -force && [[ -f tmp_dps_float16.trx/dps/dps.float16 ]] 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..b02e8a8215 --- /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 tmp_dps.txt: +for n in {0..12} +do + echo "${n}.0" >> tmp_dps.txt +done + +# Test adding a data-per-streamline value to a TRX: +tckconvert tckconvert/gs.tck tmp_dps_float32.trx -trxdirectory -dps dps float32 tmp_dps.txt -force && [[ -f tmp_dps_float32.trx/dps/dps.float32 ]] 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..f7b15c2648 --- /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 tmp_dps.txt: +for n in {0..12} +do + echo "${n}.0" >> tmp_dps.txt +done + +# Test adding a data-per-streamline value to a TRX: +tckconvert tckconvert/gs.tck tmp_dps_float64.trx -trxdirectory -dps dps float64 tmp_dps.txt -force && [[ -f tmp_dps_float64.trx/dps/dps.float64 ]] diff --git a/testing/binaries/tests/tckedit/trx_length b/testing/binaries/tests/tckedit/trx_length new file mode 100755 index 0000000000..6c89eb135b --- /dev/null +++ b/testing/binaries/tests/tckedit/trx_length @@ -0,0 +1,10 @@ +#!/bin/bash +# Validate that tckedit correctly filters TRX streamlines by length, +# producing the same result as filtering the equivalent TCK file +tckedit tckedit/in.trx -minlength 10 tmp.trx -force +tckconvert tmp.trx tmp.tck -force +testing_diff_tck tmp.tck tckedit/upper.tck + +tckedit tckedit/in.trx -maxlength 10 tmp.trx -force +tckconvert tmp.trx tmp.tck -force +testing_diff_tck tmp.tck tckedit/lower.tck diff --git a/testing/binaries/tests/tckedit/trx_preserves_metadata b/testing/binaries/tests/tckedit/trx_preserves_metadata new file mode 100755 index 0000000000..14e58c3089 --- /dev/null +++ b/testing/binaries/tests/tckedit/trx_preserves_metadata @@ -0,0 +1,8 @@ +#!/bin/bash +# Verify that tckedit preserves dps/dpv/groups metadata when filtering a TRX file. +# tckconvert/gs_fldr.trx contains dps (random_coord) and dpv (color_x/y/z) fields. +# After filtering by length, the output TRX should retain those fields, +# with dimensions remapped to the surviving streamline/vertex counts. +tckedit tckconvert/gs_fldr.trx -minlength 5 tmp.trx -force +tckinfo tmp.trx | grep -q "Data per streamline" && \ +tckinfo tmp.trx | grep -q "Data per vertex" diff --git a/testing/binaries/tests/tckgen/trx_float16 b/testing/binaries/tests/tckgen/trx_float16 new file mode 100755 index 0000000000..0573a6dc0c --- /dev/null +++ b/testing/binaries/tests/tckgen/trx_float16 @@ -0,0 +1,13 @@ +#!/bin/bash +# Verify that tckgen can write TRX output with float16 positions via -trx_float16 +# Both float32 (default) and float16 files are generated and each reports 100 streamlines +tckgen SIFT_phantom/fods.mif -algo ifod2 \ + -seed_image SIFT_phantom/mask.mif -mask SIFT_phantom/mask.mif \ + -minlength 4 -select 100 tmp_f32.trx -force +tckgen SIFT_phantom/fods.mif -algo ifod2 \ + -seed_image SIFT_phantom/mask.mif -mask SIFT_phantom/mask.mif \ + -minlength 4 -select 100 tmp_f16.trx -trx_float16 -force +tckinfo tmp_f32.trx | grep -q "TRX streamlines:.*100" +tckinfo tmp_f16.trx | grep -q "TRX streamlines:.*100" +# float16 file should be smaller than float32 (positions array is half the size) +[ $(wc -c < tmp_f16.trx) -lt $(wc -c < tmp_f32.trx) ] diff --git a/testing/binaries/tests/tckgen/trx_output b/testing/binaries/tests/tckgen/trx_output new file mode 100755 index 0000000000..94b3f5e511 --- /dev/null +++ b/testing/binaries/tests/tckgen/trx_output @@ -0,0 +1,7 @@ +#!/bin/bash +# Verify that tckgen can write TRX output directly +# Generates 100 streamlines and checks the TRX file reports 100 streamlines +tckgen SIFT_phantom/fods.mif -algo ifod2 \ + -seed_image SIFT_phantom/mask.mif -mask SIFT_phantom/mask.mif \ + -minlength 4 -select 100 tmp.trx -force +tckinfo tmp.trx | grep -q "TRX streamlines:.*100" diff --git a/testing/binaries/tests/tckinfo/trx_metadata b/testing/binaries/tests/tckinfo/trx_metadata new file mode 100755 index 0000000000..65f1f4bf25 --- /dev/null +++ b/testing/binaries/tests/tckinfo/trx_metadata @@ -0,0 +1,5 @@ +#!/bin/bash +# Verify that tckinfo correctly reads and displays metadata from a TRX file +tckinfo tckconvert/gs.trx | grep -q "TRX streamlines:.*13" && \ +tckinfo tckconvert/gs.trx | grep -q "Data per streamline" && \ +tckinfo tckconvert/gs.trx | grep -q "Data per vertex" diff --git a/testing/binaries/tests/tckmap/trx_dps_weights b/testing/binaries/tests/tckmap/trx_dps_weights new file mode 100644 index 0000000000..4485211270 --- /dev/null +++ b/testing/binaries/tests/tckmap/trx_dps_weights @@ -0,0 +1,19 @@ +#!/bin/bash +# Verify that tckmap correctly reads per-streamline weights from a TRX dps field. +# tcksift2 writes weights as a dps field "weights" in-place to the TRX file; +# tckmap should produce identical output whether weights are supplied as an +# external text file (-tck_weights_in tmp_weights.csv) or as a dps field name +# (-tck_weights_in weights). +cp SIFT_phantom/tracks.trx tmp_weighted.trx +tcksift2 tmp_weighted.trx SIFT_phantom/fods.mif weights + +# Reference: external weights file +tcksift2 SIFT_phantom/tracks.trx SIFT_phantom/fods.mif tmp_weights.csv +tckmap SIFT_phantom/tracks.trx -template SIFT_phantom/mask.mif \ + -tck_weights_in tmp_weights.csv tmp_ref_tdi.mif -force + +# Under test: weights resolved from dps field +tckmap tmp_weighted.trx -template SIFT_phantom/mask.mif \ + -tck_weights_in weights tmp_dps_tdi.mif -force + +testing_diff_image tmp_ref_tdi.mif tmp_dps_tdi.mif -frac 1e-5 diff --git a/testing/binaries/tests/tckmap/trx_length_contrast b/testing/binaries/tests/tckmap/trx_length_contrast new file mode 100644 index 0000000000..333c16e941 --- /dev/null +++ b/testing/binaries/tests/tckmap/trx_length_contrast @@ -0,0 +1,9 @@ +#!/bin/bash +# Verify that tckmap produces matching length-weighted TDI from TRX vs. TCK input. +# Convert TCK to TRX first to guarantee identical streamline data. +tckconvert SIFT_phantom/tracks.tck tmp_from_tck.trx -force +tckmap SIFT_phantom/tracks.tck -template SIFT_phantom/mask.mif \ + -contrast length tmp_tck_len.mif -force +tckmap tmp_from_tck.trx -template SIFT_phantom/mask.mif \ + -contrast length tmp_trx_len.mif -force +testing_diff_image tmp_tck_len.mif tmp_trx_len.mif -frac 1e-4 diff --git a/testing/binaries/tests/tckmap/trx_tdi b/testing/binaries/tests/tckmap/trx_tdi new file mode 100644 index 0000000000..7a049f0106 --- /dev/null +++ b/testing/binaries/tests/tckmap/trx_tdi @@ -0,0 +1,8 @@ +#!/bin/bash +# Verify that tckmap produces identical TDI when given a TRX file vs. the +# equivalent TCK file (same streamlines, no weights). +# Convert TCK to TRX first to guarantee identical streamline data. +tckconvert SIFT_phantom/tracks.tck tmp_from_tck.trx -force +tckmap SIFT_phantom/tracks.tck -template SIFT_phantom/mask.mif tmp_tck_tdi.mif -force +tckmap tmp_from_tck.trx -template SIFT_phantom/mask.mif tmp_trx_tdi.mif -force +testing_diff_image tmp_tck_tdi.mif tmp_trx_tdi.mif -abs 1.5 diff --git a/testing/binaries/tests/tckresample/trx_downsample b/testing/binaries/tests/tckresample/trx_downsample new file mode 100755 index 0000000000..bf85f0447b --- /dev/null +++ b/testing/binaries/tests/tckresample/trx_downsample @@ -0,0 +1,11 @@ +#!/bin/bash +# Verify that tckresample produces the same geometry on TRX input as TCK input, +# and that dps metadata is preserved through the resampling step. +tckconvert tracks.tck tmp_in.trx -force +tckstats tmp_in.trx -dump lengths +tckresample tmp_in.trx tmp_out.trx -downsample 2 -force +# geometry must match the TCK reference +tckconvert tmp_out.trx tmp_out.tck -force +testing_diff_tck tmp_out.tck tckresample/downsample.tck +# dps field must be preserved in the output +tckinfo tmp_out.trx | grep -q "lengths:" diff --git a/testing/binaries/tests/tcksample/trx_dps_mean b/testing/binaries/tests/tcksample/trx_dps_mean new file mode 100755 index 0000000000..79da2936ff --- /dev/null +++ b/testing/binaries/tests/tcksample/trx_dps_mean @@ -0,0 +1,6 @@ +#!/bin/bash +# Verify that tcksample embeds per-streamline statistics as a dps field directly +# in the input TRX file when -stat_tck is given and the output argument has no extension. +cp tracks.trx tmp_samp_trx.trx +tcksample tmp_samp_trx.trx tcksample/fa.mif fa_mean -stat_tck mean +tckinfo tmp_samp_trx.trx | grep -q "fa_mean:" diff --git a/testing/binaries/tests/tcksample/trx_dpv b/testing/binaries/tests/tcksample/trx_dpv new file mode 100755 index 0000000000..e2a937ab08 --- /dev/null +++ b/testing/binaries/tests/tcksample/trx_dpv @@ -0,0 +1,6 @@ +#!/bin/bash +# Verify that tcksample embeds per-vertex sampled values as a dpv field directly +# in the input TRX file when a bare field name is given as the output argument. +cp tracks.trx tmp_samp_trx.trx +tcksample tmp_samp_trx.trx tcksample/fa.mif fa +tckinfo tmp_samp_trx.trx | grep -q "fa:" diff --git a/testing/binaries/tests/tcksift/trx_subset b/testing/binaries/tests/tcksift/trx_subset new file mode 100755 index 0000000000..78ff5ba947 --- /dev/null +++ b/testing/binaries/tests/tcksift/trx_subset @@ -0,0 +1,12 @@ +#!/bin/bash +# Verify that tcksift correctly subsets a TRX tractogram, +# achieving the same density balance as when operating on TCK input. +# The SIFT phantom has two bundles of unequal length; after SIFT the +# density (TDI) of both bundles should be approximately equal. +rm -rf tmp_debug/ +tcksift SIFT_phantom/tracks.trx SIFT_phantom/fods.mif tmp.trx -output_debug tmp_debug/ -csv tmp.csv -force +tckconvert tmp.trx tmp.tck -force +tckmap tmp.tck -template SIFT_phantom/mask.mif -precise tmp.mif -force +mrstats tmp.mif -mask SIFT_phantom/upper.mif -output mean > tmp1.txt +mrstats tmp.mif -mask SIFT_phantom/lower.mif -output mean > tmp2.txt +testing_diff_matrix tmp1.txt tmp2.txt -abs 10 diff --git a/testing/binaries/tests/tcksift2/trx_dps b/testing/binaries/tests/tcksift2/trx_dps new file mode 100755 index 0000000000..0dc5d8ae0e --- /dev/null +++ b/testing/binaries/tests/tcksift2/trx_dps @@ -0,0 +1,6 @@ +#!/bin/bash +# Verify that tcksift2 can embed per-streamline weights as a dps field directly +# in the input TRX file when a bare field name is given as the output argument. +cp SIFT_phantom/tracks.trx tmp_sift2_trx.trx +tcksift2 tmp_sift2_trx.trx SIFT_phantom/fods.mif weights +tckinfo tmp_sift2_trx.trx | grep -q "weights:.*5000" diff --git a/testing/binaries/tests/tckstats/trx_dps b/testing/binaries/tests/tckstats/trx_dps new file mode 100644 index 0000000000..342f74ddec --- /dev/null +++ b/testing/binaries/tests/tckstats/trx_dps @@ -0,0 +1,6 @@ +#!/bin/bash +# Verify that -dump with a bare field name appends per-streamline lengths as a +# dps field in-place to the input TRX file. +cp SIFT_phantom/tracks.trx tmp_lengths.trx +tckstats tmp_lengths.trx -dump lengths +tckinfo tmp_lengths.trx | grep -q "lengths:.*5000" diff --git a/testing/binaries/tests/tckstats/trx_stats b/testing/binaries/tests/tckstats/trx_stats new file mode 100644 index 0000000000..8c28a025b5 --- /dev/null +++ b/testing/binaries/tests/tckstats/trx_stats @@ -0,0 +1,6 @@ +#!/bin/bash +# Verify that tckstats reports identical statistics for TRX and TCK inputs +# containing the same streamlines. +tckstats SIFT_phantom/tracks.tck -output count -output mean -output std -output min -output max > tmp_tck.txt +tckstats SIFT_phantom/tracks.trx -output count -output mean -output std -output min -output max > tmp_trx.txt +testing_diff_matrix tmp_tck.txt tmp_trx.txt -abs 1e-4 diff --git a/testing/binaries/tests/tcktransform/trx_geometry b/testing/binaries/tests/tcktransform/trx_geometry new file mode 100644 index 0000000000..353d70a026 --- /dev/null +++ b/testing/binaries/tests/tcktransform/trx_geometry @@ -0,0 +1,7 @@ +#!/bin/bash +# Verify that tcktransform on TRX input produces the same warped geometry as +# on the equivalent TCK input. Both outputs are compared against the +# pre-existing TCK reference (tcktransform/out.tck). +tckconvert tracks.tck tmp_in.trx -force +tcktransform tmp_in.trx unit_warp.mif tmp_out.tck -force +testing_diff_tck tmp_out.tck tcktransform/out.tck -distance 1e-4 diff --git a/testing/binaries/tests/tcktransform/trx_preserves_metadata b/testing/binaries/tests/tcktransform/trx_preserves_metadata new file mode 100644 index 0000000000..1e417a247c --- /dev/null +++ b/testing/binaries/tests/tcktransform/trx_preserves_metadata @@ -0,0 +1,8 @@ +#!/bin/bash +# Verify that the TRX→TRX in-place path preserves dps metadata after warping. +# A "lengths" dps field is written by tckstats; after tcktransform the field +# must still be present with the same count. +tckconvert tracks.tck tmp_in.trx -force +tckstats tmp_in.trx -dump lengths +tcktransform tmp_in.trx unit_warp.mif tmp_out.trx -force +tckinfo tmp_out.trx | grep -q "lengths:" diff --git a/testing/binaries/tests/trx2connectome/all_voxels_parity b/testing/binaries/tests/trx2connectome/all_voxels_parity new file mode 100644 index 0000000000..d0bc876c6c --- /dev/null +++ b/testing/binaries/tests/trx2connectome/all_voxels_parity @@ -0,0 +1,14 @@ +#!/bin/bash +# Verify that trx2connectome produces correct inter-node connectivity when +# trxlabel used -assignment_all_voxels. The TRX representation stores group +# memberships without encoding the assignment mode, so trx2connectome uses +# endpoint-pair semantics (one edge per streamline, no self-connections). +# The off-diagonal connectivity must match tck2connectome (default endpoint mode). +tck2connectome SIFT_phantom/tracks.tck SIFT_phantom/parc.mif tmp_tck_matrix.csv \ + -force + +trxlabel SIFT_phantom/tracks.trx tmp_labeled_allvox.trx \ + -nodes SIFT_phantom/parc.mif -assignment_all_voxels -force +trx2connectome tmp_labeled_allvox.trx tmp_trx_matrix.csv -force + +testing_diff_matrix tmp_trx_matrix.csv tmp_tck_matrix.csv diff --git a/testing/binaries/tests/trx2connectome/default b/testing/binaries/tests/trx2connectome/default new file mode 100644 index 0000000000..c0d40e5db2 --- /dev/null +++ b/testing/binaries/tests/trx2connectome/default @@ -0,0 +1,10 @@ +#!/bin/bash +# Verify that the trxlabel + trx2connectome pipeline produces the same connectivity +# matrix as tck2connectome on the same data. +# Reference: tck2connectome/out.csv (4x4 matrix, edge(1,2)=3384, edge(3,4)=1616) +trxlabel SIFT_phantom/tracks.trx tmp_labeled.trx -nodes SIFT_phantom/parc.mif -force +trx2connectome tmp_labeled.trx tmp_matrix.csv -out_node_names tmp_names.txt -force +testing_diff_matrix tmp_matrix.csv tck2connectome/out.csv +# Node names should be the 4 numeric node IDs in order +grep -q "^1$" tmp_names.txt +grep -q "^4$" tmp_names.txt diff --git a/testing/binaries/tests/trx2connectome/forward_search b/testing/binaries/tests/trx2connectome/forward_search new file mode 100644 index 0000000000..68fc1bdaa1 --- /dev/null +++ b/testing/binaries/tests/trx2connectome/forward_search @@ -0,0 +1,11 @@ +#!/bin/bash +# Verify that trxlabel + trx2connectome with -assignment_forward_search produces +# the same result as tck2connectome with the same option. +# The SIFT phantom is simple enough that the forward-search and radial-search +# assignment methods produce identical results (both reference files are the same). +trxlabel SIFT_phantom/tracks.trx tmp_labeled.trx \ + -nodes SIFT_phantom/parc.mif \ + -assignment_forward_search 5 \ + -force +trx2connectome tmp_labeled.trx tmp_matrix.csv -force +testing_diff_matrix tmp_matrix.csv tck2connectome/out.csv diff --git a/testing/binaries/tests/trx2connectome/group_prefix b/testing/binaries/tests/trx2connectome/group_prefix new file mode 100644 index 0000000000..92e78cd877 --- /dev/null +++ b/testing/binaries/tests/trx2connectome/group_prefix @@ -0,0 +1,17 @@ +#!/bin/bash +# Verify that -group_prefix correctly filters the connectome to only groups +# matching the prefix, and that the output matrix matches the full matrix +# when using a single atlas (all groups share the same prefix). +trxlabel SIFT_phantom/tracks.trx tmp_labeled.trx \ + -nodes SIFT_phantom/parc.mif \ + -prefix myatlas \ + -force +trx2connectome tmp_labeled.trx tmp_matrix.csv \ + -group_prefix myatlas \ + -out_node_names tmp_names.txt \ + -force +# Matrix should still match the reference (same connectivity, same node ordering) +testing_diff_matrix tmp_matrix.csv tck2connectome/out.csv +# Node names file should have prefix stripped: "1", "2", "3", "4" +grep -q "^1$" tmp_names.txt +grep -q "^4$" tmp_names.txt diff --git a/testing/binaries/tests/trxlabel/default b/testing/binaries/tests/trxlabel/default new file mode 100644 index 0000000000..0c603a5dfb --- /dev/null +++ b/testing/binaries/tests/trxlabel/default @@ -0,0 +1,11 @@ +#!/bin/bash +# Verify that trxlabel assigns streamlines to groups based on a parcellation image. +# Uses the SIFT phantom: 4-node parcellation, 5000 streamlines connecting nodes 1-2 and 3-4. +# After labeling, the TRX should have exactly 4 groups whose combined membership +# accounts for all streamlines. +trxlabel SIFT_phantom/tracks.trx tmp.trx -nodes SIFT_phantom/parc.mif -force +# All 4 nodes should appear as groups +tckinfo tmp.trx | grep -q "Groups (4)" +# Spot-check that group "1" and "2" exist (the two nodes for bundle 1) +tckinfo tmp.trx | grep -q "1:" +tckinfo tmp.trx | grep -q "2:" diff --git a/testing/binaries/tests/trxlabel/multiple_atlases b/testing/binaries/tests/trxlabel/multiple_atlases new file mode 100644 index 0000000000..803b104110 --- /dev/null +++ b/testing/binaries/tests/trxlabel/multiple_atlases @@ -0,0 +1,14 @@ +#!/bin/bash +# Verify that trxlabel can process two atlases in a single invocation using -prefix. +# Both atlases are the same parc.mif here; with different prefixes the groups +# should have distinct names (a_1, a_2, a_3, a_4, b_1, b_2, b_3, b_4 = 8 groups total). +trxlabel SIFT_phantom/tracks.trx tmp.trx \ + -nodes SIFT_phantom/parc.mif \ + -nodes SIFT_phantom/parc.mif \ + -prefix a \ + -prefix b \ + -force +# 8 groups expected (4 nodes × 2 atlases) +tckinfo tmp.trx -prefix_depth 0 | grep -q "Groups (8)" +tckinfo tmp.trx -prefix_depth 0 | grep -q "a_1:" +tckinfo tmp.trx -prefix_depth 0 | grep -q "b_1:" diff --git a/testing/binaries/tests/tsfdivide/trx_dpv b/testing/binaries/tests/tsfdivide/trx_dpv new file mode 100644 index 0000000000..216d4cc0f6 --- /dev/null +++ b/testing/binaries/tests/tsfdivide/trx_dpv @@ -0,0 +1,15 @@ +#!/bin/bash +# Verify that tsfdivide on TRX dpv fields appends the ratio as a new dpv field. +cp tracks.trx tmp_tsfdiv.trx +tcksample tmp_tsfdiv.trx tcksample/fa.mif fa +# Create a second dpv field (smoothed) to divide by +tsfsmooth tmp_tsfdiv.trx tmp_tsfdiv.trx -field_in fa -field_out fa_smooth -force +# Divide fa / fa_smooth +tsfdivide tmp_tsfdiv.trx tmp_tsfdiv.trx tmp_tsfdiv.trx \ + -field1 fa -field2 fa_smooth -field_out fa_ratio -force +# Verify the ratio field was added +tsfinfo tmp_tsfdiv.trx 2>&1 | grep -q "fa_ratio:" +# Regression: TSF/TSF→TSF path still works +tsfsmooth tcksample/out.tsf tmp_tsfdiv_smooth.tsf -force +tsfdivide tcksample/out.tsf tmp_tsfdiv_smooth.tsf tmp_tsfdiv_ref.tsf -force +tsfinfo tmp_tsfdiv_ref.tsf 2>&1 | grep -q "count:.*500" diff --git a/testing/binaries/tests/tsfinfo/trx_dpv b/testing/binaries/tests/tsfinfo/trx_dpv new file mode 100644 index 0000000000..2ebacb83de --- /dev/null +++ b/testing/binaries/tests/tsfinfo/trx_dpv @@ -0,0 +1,7 @@ +#!/bin/bash +# Verify that tsfinfo prints dpv field info for a TRX file. +cp tracks.trx tmp_tsfinfo.trx +tcksample tmp_tsfinfo.trx tcksample/fa.mif fa +tsfinfo tmp_tsfinfo.trx 2>&1 | grep -q "Streamlines:" +tsfinfo tmp_tsfinfo.trx 2>&1 | grep -q "DPV fields" +tsfinfo tmp_tsfinfo.trx 2>&1 | grep -q "fa:" diff --git a/testing/binaries/tests/tsfmult/trx_dpv b/testing/binaries/tests/tsfmult/trx_dpv new file mode 100644 index 0000000000..c7a8f3dc47 --- /dev/null +++ b/testing/binaries/tests/tsfmult/trx_dpv @@ -0,0 +1,15 @@ +#!/bin/bash +# Verify that tsfmult on TRX dpv fields appends the product as a new dpv field. +cp tracks.trx tmp_tsfmul.trx +tcksample tmp_tsfmul.trx tcksample/fa.mif fa +# Create a binary mask dpv field via threshold +tsfthreshold tmp_tsfmul.trx 0.3 tmp_tsfmul.trx -field_in fa -field_out fa_mask -force +# Multiply mask × fa to get masked fa +tsfmult tmp_tsfmul.trx tmp_tsfmul.trx tmp_tsfmul.trx \ + -field1 fa_mask -field2 fa -field_out fa_masked -force +# Verify the masked field was added +tsfinfo tmp_tsfmul.trx 2>&1 | grep -q "fa_masked:" +# Regression: TSF/TSF→TSF path still works +tsfthreshold tcksample/out.tsf 0.3 tmp_tsfmul_mask.tsf -force +tsfmult tmp_tsfmul_mask.tsf tcksample/out.tsf tmp_tsfmul_ref.tsf -force +tsfinfo tmp_tsfmul_ref.tsf 2>&1 | grep -q "count:.*500" diff --git a/testing/binaries/tests/tsfsmooth/trx_dpv b/testing/binaries/tests/tsfsmooth/trx_dpv new file mode 100644 index 0000000000..990943914a --- /dev/null +++ b/testing/binaries/tests/tsfsmooth/trx_dpv @@ -0,0 +1,9 @@ +#!/bin/bash +# Verify that tsfsmooth on a TRX dpv field appends a smoothed dpv field. +cp tracks.trx tmp_tsfsmo.trx +tcksample tmp_tsfsmo.trx tcksample/fa.mif fa +# Smooth the dpv field in-place (add fa_smooth) +tsfsmooth tmp_tsfsmo.trx tmp_tsfsmo.trx -field_in fa -field_out fa_smooth -force +# Verify both fields now exist +tsfinfo tmp_tsfsmo.trx 2>&1 | grep -q "fa:" +tsfinfo tmp_tsfsmo.trx 2>&1 | grep -q "fa_smooth:" diff --git a/testing/binaries/tests/tsfthreshold/trx_dpv b/testing/binaries/tests/tsfthreshold/trx_dpv new file mode 100644 index 0000000000..167ba8f8eb --- /dev/null +++ b/testing/binaries/tests/tsfthreshold/trx_dpv @@ -0,0 +1,11 @@ +#!/bin/bash +# Verify that tsfthreshold on a TRX dpv field appends the result as a new dpv field. +cp tracks.trx tmp_tsfthr.trx +tcksample tmp_tsfthr.trx tcksample/fa.mif fa +# Threshold the dpv field in-place (add new field fa_mask) +tsfthreshold tmp_tsfthr.trx 0.3 tmp_tsfthr.trx -field_in fa -field_out fa_mask -force +# Verify the new dpv field was added +tsfinfo tmp_tsfthr.trx 2>&1 | grep -q "fa_mask:" +# Extract the result as TSF and verify its stream count +tsfthreshold tmp_tsfthr.trx 0.3 tmp_tsfthr_out.tsf -field_in fa -force +tsfinfo tmp_tsfthr_out.tsf 2>&1 | grep -q "count:.*500" diff --git a/testing/binaries/tests/tsfvalidate/trx_dpv b/testing/binaries/tests/tsfvalidate/trx_dpv new file mode 100644 index 0000000000..5b849648e8 --- /dev/null +++ b/testing/binaries/tests/tsfvalidate/trx_dpv @@ -0,0 +1,6 @@ +#!/bin/bash +# Verify that tsfvalidate -field checks a TRX dpv field for consistency. +cp tracks.trx tmp_tsfval.trx +tcksample tmp_tsfval.trx tcksample/fa.mif fa +# Valid field: should succeed +tsfvalidate tmp_tsfval.trx tmp_tsfval.trx -field fa diff --git a/trx_integration.md b/trx_integration.md new file mode 100644 index 0000000000..b983e4b5b4 --- /dev/null +++ b/trx_integration.md @@ -0,0 +1,807 @@ +# TRX Integration Plan for MRtrix3 + +## Design Principles + +**Format branching belongs in `trx_utils.h`, not in command code, if possible.** + +Commands should not contain `if (is_trx_input)` branches. Any logic that differs between TCK and TRX should preferably be abstracted into a helper in `trx_utils.h` so that the command sees a single unified API. This keeps individual commands maintainable and keeps the integration surface contained. + +**Sidecar data resolution: transparent embedded-or-external lookup.** + +Commands that accept external sidecar files (e.g., `-tck_weights_in weights.txt`, `-tsf scalars.tsf`) should require no new CLI options for TRX. Instead, `trx_utils.h` provides resolution helpers that: +1. If the argument value is a valid file path, load from that external file as normal (full backward compatibility). +2. If the input tractogram is a TRX file and the argument value is a plain field name (no file extension / not a path that exists on disk), look up that name in `data_per_streamline` (for weight-like data) or `data_per_vertex` (for TSF-like data) and return the values directly. + +This means users can write: +``` +tckmap input.trx output.mif -tck_weights_in weights # reads dps["weights"] from TRX +tckmap input.trx output.mif -tck_weights_in weights.txt # reads external text file +tckmap input.tck output.mif -tck_weights_in weights.txt # unchanged existing behaviour +``` + +The option argument type stays `type_file_in()` for external files. The resolution helper does the dispatch; the command code is unchanged. + +Concretely, `trx_utils.h` should provide: +- `open_tractogram(path, properties)` → `unique_ptr>`: opens TCK or TRX, populates `Properties["count"]`. For TRX, step size is left unset (so `determine_upsample_ratio` naturally returns 1). +- `resolve_dps_weights(trx, field_name_or_path)` → `vector`: returns per-streamline weights from an embedded dps field or an external file. +- `resolve_dpv_scalars(trx, field_name_or_path)` → `vector`: returns flat per-vertex scalars from an embedded dpv field or an external TSF file. +- Any other format-agnostic entry points needed by commands. + +The goal is that a command's `run()` function looks virtually identical for TCK and TRX inputs — only output path (`.trx` vs `.tck`) or genuinely new capabilities cause visible branching. + +## Core Architectural Insight + +MRtrix3's tractography commands all work through `ReaderInterface` / `WriterInterface` and the `Streamline` type. TRX's extra dimensions (dps, dpv, groups, dpg) have **no equivalent abstraction** in MRtrix3 today — data like TSF scalars and SIFT weights live in separate sidecar files. + +The smoothest integration strategy is a **two-layer approach**: + +1. **Layer 1 (Format layer)**: TRX as a first-class file format wherever TCK is accepted — reading/writing streamline geometry through the existing `ReaderInterface`/`WriterInterface` pattern. + +2. **Layer 2 (Metadata layer)**: Per-command opt-in to TRX's richer data model (dps/dpv/groups/dpg), where each command that produces sidecar data gains a `-trx` option to embed that data directly. + +## Phase 1: Foundation — Shared TRX Utilities + +**File: `cpp/core/dwi/tractography/trx_utils.h`** + +### Done + +Shared utilities in `MR::DWI::Tractography::TRX` namespace: +- `TRXReader` class — streamline-by-streamline iteration over a TRX file +- `TRXWriter` class — accumulates streamlines into a preallocated TrxFile, with dps/dpv added via `get_trx()` +- `is_trx(path)` — detects `.trx` suffix or TRX directory +- `load_trx(path)` → `unique_ptr>` — handles float16/float32/float64 positions correctly: + - float32: direct `trx::load()`, zero-copy mmap for positions. + - float16 or float64: double-load — first pass as float32 (correct for dps/dpv/groups which have their own dtype casting in trx-cpp); second pass via `trx::with_trx_reader` with the native dtype to copy-convert coordinates into `streamlines->_data_owned`; then `trx::detail::remap` rebinds `_data` to the owned buffer before any position access. The float32 pass leaves `_data` temporarily pointing at an undersized mmap but this is never dereferenced until after the remap. A WARN is issued to inform the user of the conversion. + - Note: the free functions `trx::load_from_directory
` / `trx::load_from_zip
` declared in `trx.h` are never defined; only their `TrxFile
::` static-member counterparts exist in `trx.tpp`. Use `trx::load()` or `trx::with_trx_reader()` instead. +- `count_trx(path)` — returns `(nb_streamlines, nb_vertices)` +- `has_aux_data(trx)` — checks for groups, dps, dpv, or dpg +- `print_info(ostream, trx)` — prints full TRX metadata (header, groups, dps, dpv, dpg) +- `make_typed_array(vector, cols)` — constructs a `trx::TypedArray` from a vector +- `append_dps(path, name, values)` — appends dps to existing TRX (zip or directory) +- `append_dpv(path, name, values)` — appends dpv to existing TRX (zip or directory) +- `TRXScalarReader(path, field_name)` — reads a named dpv field as a stream of `TrackScalar`, one per streamline; eagerly copies data so the mmap is released on construction (enabling in-place append to the same file) +- `TRXScalarWriter(path, field_name)` — accumulates `TrackScalar` values and appends them as a dpv field to an existing TRX on `finalize()` + +Infrastructure fixes enabling command-level TRX support without branching (DONE): +- `loader.h`: `TrackLoader` constructor now accepts `ReaderInterface&` instead of `Reader<>&`, so `TRXReader` can be passed directly. +- `mapping.cpp/h`: added `generate_header(Header&, ReaderInterface&, voxel_size)` overload; the path-based version delegates to it. + +### Done — helpers for command-level integration + +- `open_tractogram(path, properties)` → `unique_ptr>`: opens TCK or TRX transparently, populates `properties["count"]`. Single entry point replacing `Reader file(path, properties)` in every command. +- `resolve_dps_weights(tractogram_path, field_name_or_path)` → `vector`: if the string is an existing file path, loads as a text weight vector (tck_weights_in format); otherwise looks up the name in the TRX dps map. +- `resolve_dpv_scalars(tractogram_path, field_name_or_path)` → `vector`: same pattern for per-vertex scalars. File-path case currently handles text format; proper binary TSF support is deferred (use `Tractography::ScalarFile` directly for TSF files for now). + +With these three helpers, commands need only: +1. Swap `Reader file(argument[0], properties)` → `auto reader = TRX::open_tractogram(argument[0], properties)` (one line) +2. Swap `TrackLoader loader(file, ...)` → `TrackLoader loader(*reader, ...)` (one line, already works via the `loader.h` fix) +3. Update `have_weights` to check both `tck_weights_in` and `resolve_dps_weights` as needed + +## Phase 2: Command-by-Command Integration + +### 2a. `tckinfo` — Print TRX metadata (DONE) + +When input is TRX, `tckinfo` now: +- Detects TRX files via `TRX::is_trx()` +- Loads via `TRX::load_trx()` +- Prints header info (NB_STREAMLINES, NB_VERTICES, VOXEL_TO_RASMM, DIMENSIONS) +- Lists groups with member counts +- Lists dps fields with dimensions +- Lists dpv fields with dimensions +- Lists dpg fields per group + +Also accepts `.trx` files and TRX directories as input arguments (via `type_file_in().type_directory_in()`). + +### 2b. `fixel2tsf` → add `-trx_dpv` option (DONE) + +Added `-trx_dpv trxpath name` option that appends per-vertex fixel values as a dpv entry to an existing TRX file. + +Implementation: +- Added `append_dpv()` utility to `trx_utils.h` (mirrors `append_dps`) +- During the main loop, when `-trx_dpv` is specified, per-vertex scalars are accumulated into a flat `vector` +- After the loop completes, `TRX::append_dpv()` writes the dpv in one shot +- The TSF output (`argument[2]`) is still always produced; `-trx_dpv` is an additional output + +Note: under the new design principle (no new CLI options; resolve from embedded TRX data transparently), the `-trx_dpv` option style is legacy. The write direction (appending dpv to an existing TRX) is still a valid pattern since there is no other way to embed new data. The read direction (consuming dpv instead of TSF) should use `resolve_dpv_scalars` once that helper exists. + +### 2c. `tcksift2` → add `-trx_dps` option (DONE) + +Added `-trx_dps trxpath name` option that appends SIFT2 weights as a dps entry to an existing TRX file. + +Implementation: +- Added `get_factors()` method to `TckFactor` (returns `std::vector` of exponentiated weights) +- Added `make_typed_array()` and `append_dps()` utilities to `trx_utils.h` +- Uses `trx::append_dps_to_zip()` or `trx::append_dps_to_directory()` (auto-detected) to append without rewriting +- The text weight output (`argument[2]`) is still always produced; `-trx_dps` is an additional output + +### 2d. `trxlabel` + `trx2connectome` → TRX-native connectome pipeline (DONE) + +Two new commands replace the `tck2connectome` workflow for TRX files. + +#### `trxlabel` — assign streamlines to groups by parcellation atlas + +**Usage**: `trxlabel tracks_in.trx nodes.mif tracks_out.trx [-lut lut.txt] [-prefix str] [assignment options]` + +Assigns each streamline to one or two TRX groups based on the parcellation nodes touched by its endpoints. Uses the same `Tck2nodes_base` assignment machinery as `tck2connectome` (default: 4 mm radial search). Each node that receives at least one endpoint assignment becomes a group; the group name is the node index or the LUT name if `-lut` is provided. + +- The output TRX can be the same file as the input (in-place labeling via `append_groups_to_zip/directory`). +- `-prefix str` namespaces groups from a given atlas (e.g. `-prefix dk` → groups named `dk_Left-Hippocampus`). Run multiple times with different atlases and prefixes to accumulate groups from multiple parcellation schemes. +- Supports all assignment options from `tck2connectome` (`-assignment_radial_search`, `-assignment_end_voxels`, etc.). +- Supports all LUT formats (FreeSurfer, AAL, ITK-SNAP, MRtrix). + +Implementation: +- `load_trx()` + direct position iteration replaces `TRXReader` so the TrxFile can be saved before closing +- After computing assignments, `trx->save(output_path)` copies the full TRX (including any existing groups/dps/dpv) +- `trx->close(); trx.reset()` releases the mmap before appending +- `append_groups_to_zip/directory()` adds the new groups to the saved file + +#### `trx2connectome` — build connectome matrix from TRX groups + +**Usage**: `trx2connectome tracks_labeled.trx connectome.csv [-tck_weights_in weights] [-out_node_names names.txt] [-group_prefix prefix]` + +Reads TRX groups, inverts the group→index mapping to build a per-streamline node pair list, then feeds these into the existing `Matrix` accumulator. No parcellation image is needed — the assignments are already embedded. + +- `-tck_weights_in` accepts either an external text file or a TRX dps field name (via `resolve_dps_weights`). +- `-group_prefix` restricts the matrix to groups matching a given prefix (useful when multiple atlases were labeled into the same TRX). +- `-out_node_names path` writes the alphabetically-ordered group names (one per line) corresponding to matrix rows/columns. +- Supports all `Matrix` options: `-stat_edge`, `-symmetric`, `-zero_diagonal`, `-keep_unassigned`. +- Streamlines in exactly 2 groups contribute to one off-diagonal entry; streamlines in 3+ groups contribute to all unique pairs; streamlines in 0 or 1 groups are accumulated at the unassigned (0,0) or (0,node) cells (discarded by default). + + +### 2e. `connectome2tck` → read groups/dps from TRX (DONE) + +When input is TRX, passing `"-"` as `assignments_in` triggers TRX group mode: node +assignments are derived from the embedded group membership (as written by `trxlabel`) +instead of a separate text file. Existing text-file mode is fully preserved. + +**CLI change**: `assignments_in` changed from `type_file_in()` to `type_text()` so the +argument is not validated as a file at parse time. The `run()` function dispatches: +- `assignments_arg == "-"` or `is_trx(assignments_arg)` with TRX input → group mode +- otherwise → text file mode (validates file existence itself) + +**New option**: `-group_prefix prefix` — filters to groups whose name starts with +`prefix_` (same semantics as in `trx2connectome`). Useful when a TRX file has groups +from multiple atlases; has no effect in text-file mode. + +**`assignments_from_trx_groups()` static helper** (before `run()`): +- Loads TRX, collects groups matching the prefix +- Tries to parse group names as integers after stripping the prefix — succeeds for the + common case of `trxlabel` without `-lut`, so `-nodes 1,2` refers to the same atlas + parcels as in the TCK/tck2connectome workflow +- Falls back to alphabetical 1-based ordering for LUT-based names (matches `trx2connectome`) +- Inverts the group → streamline mapping to produce per-streamline node lists, sorted +- Returns `vector>` compatible with the rest of `run()` unchanged + +**Reader change**: `Reader reader(path, properties)` replaced by +`open_tractogram(path, properties)` → `unique_ptr>`. All four +call sites changed from `reader(out)` to `(*reader)(out)`. The polymorphic reader +handles both TCK and TRX geometry transparently; `properties["count"]` is populated +correctly for both formats. + +**What is NOT changed**: the output format stays as TCK files. Writing per-edge TRX +groups in a single file would produce O(n²) groups for typical parcellations, which the +design doc explicitly discourages. The extraction logic (pair optimisation, +`-nodes`/`-exclusive`/`-keep_self`, exemplar generation) is identical for both +input modes once `assignments_lists` is populated. + +Typical workflow: +``` +trxlabel tracks.trx nodes.mif labeled.trx +connectome2tck labeled.trx - edge- # derive from groups +connectome2tck labeled.trx - edge- -group_prefix dk # one atlas from multi-atlas TRX +connectome2tck labeled.trx assignments.txt edge- # still works with text file +``` + +### 2f. `tcksample` → add `-trx_dpv` and `-trx_dps` options (DONE — write pattern is correct) + +Added `-trx_dpv trxpath name` and `-trx_dps trxpath name` options. + +Implementation: +- **DPV** (`-trx_dpv`): When no `-stat_tck` is used (per-vertex output mode), `Receiver_NoStatistic` optionally accumulates all per-vertex scalars into a flat `vector`. After the threaded queue completes, `TRX::append_dpv()` writes the dpv in one shot. +- **DPS** (`-trx_dps`): When `-stat_tck` is specified (per-streamline statistics mode), `Receiver_Statistic` exposes the result vector via `append_to_trx()`. After saving the text output, `TRX::append_dps()` appends the stats as a dps field. +- Both options validate the TRX path via `TRX::is_trx()` before running. +- The normal output (`argument[2]`) is always produced; the TRX options are additional outputs. + +### 2g. `tcksift` → TRX-aware subsetting (DONE) + +When input is TRX and output is `.trx`, `tcksift` now uses `trx::TrxFile::subset_streamlines()` to produce a metadata-preserving filtered TRX output (dps/dpv/groups all remapped to the surviving streamlines). + +Implementation: +- `TRXReader::operator()` now calls `tck.set_index(current)` so the SIFT model's `contributions` array is indexed correctly +- `TRXReader::num_streamlines()` accessor added +- `Model::map_streamlines()` detects TRX input and uses `TRXReader` directly (no `TrackLoader` which requires `Reader<>`) +- `SIFTer::get_selected_indices()` returns `std::vector` of kept streamline indices from `contributions` +- `SIFTer::output_filtered_tracks()` handles TRX input path (for TRX-in + TCK-out, e.g. `-output_at_counts`) +- `tcksift.cpp`: argument[0] accepts `.trx`/directory; argument[2] accepts `.trx`; when output is TRX, calls `load_trx` → `get_selected_indices` → `subset_streamlines` → `save`; otherwise calls existing `output_filtered_tracks` + +### 2h. `tckstats` → add `-trx_dps` for lengths (DONE) + +Implementation: +- `Reader` replaced by `open_tractogram(argument[0], properties, weight_src)` — handles both TCK and TRX; dps weights injected automatically when `-tck_weights_in ` is used. +- `dump` vector moved outside the reader scope block so it is available for `-trx_dps` output after the reader closes. +- Added `-trx_dps path name` option: after the main loop, calls `append_dps(path, name, dump)` to embed per-streamline lengths as a dps field. NaN values are stored for empty/degenerate streamlines, matching the `-dump` text output. +- `#include "dwi/tractography/file.h"` removed; `trx_utils.h` included instead. +- `weights_provided` check remains `!get_options("tck_weights_in").empty()` — correct for both TCK and TRX since the user must explicitly name a weight source. + +### 2i. `tckgen` → streaming TRX output (DONE) + +`tckgen` now writes `.trx` output natively via `trx::TrxStream`. + +Implementation (zero changes to `tckgen.cpp`): +- `AbstractTrackWriter` interface added to `write_kernel.h`: owns `count`/`total_count`, `operator()(Streamline)`, `skip()`. +- `WriteKernel::writer` changed from `Writer` (concrete) to `unique_ptr`. +- `TCKWriterAdapter` wraps `Writer` behind the interface. +- `TRXWriterAdapter` holds a `trx::TrxStream`; calls `push_streamline()` per track; calls `stream.finalize(path)` in its destructor. +- `WriteKernel::create_writer()` static factory detects format from path (`.trx` suffix or `TRX::is_trx()`) and returns the appropriate adapter. +- `dynamic.cpp`: `writer.count` → `writer->count` (the only other file that touched `WriteKernel::writer` directly). +- `core/CMakeLists.txt`: added `trx-cpp::trx` to `mrtrix-core` public link libraries. +- `cmake/Dependencies.cmake`: FetchContent for trx-cpp uses local `file://${PROJECT_SOURCE_DIR}/../trx-cpp` at `GIT_TAG HEAD`. +- Thread safety: `TrxStream::push_streamline()` is called only from the single writer thread — matches `WriteKernel`'s existing design. +- **`-trx_float16` flag**: sets `properties["trx_positions_dtype"] = "float16"` in `tckgen::run()`. `WriteKernel::create_writer()` reads this property and passes it to `TRXWriterAdapter`, which forwards it to `trx::TrxStream(positions_dtype)`. Default is `"float32"` (MRtrix's internal precision); float16 is opt-in because it silently quantizes coordinates to ~0.02–0.05 mm precision. +- **`app.cpp` TracksIn/TracksOut validation**: relaxed from `.tck`-only to `.tck` or `.trx`, enabling tckgen to accept `.trx` as a valid output argument without an error at argument-parsing time. + +## Phase 3: Geometry I/O — Accept TRX wherever TCK is accepted + +These commands modify or pass through streamline geometry. TRX support means accepting `.trx` input and producing `.trx` output while preserving metadata where appropriate. + +### 3a. `tckedit` — metadata-aware filtering (DONE) + +When input is TRX and output is `.trx`, `tckedit` uses `trx::TrxFile::subset_streamlines()` to produce a metadata-preserving filtered TRX output (dps/dpv/groups all remapped to the surviving streamlines). + +Implementation: +- Added `TRXIndexCollector` class (local to `tckedit.cpp`): receives passing streamlines from the existing `Worker`, records their original TRX index, respects `-number` and `-skip` limits +- `run()` detects TRX mode (`trx_in` and `trx_out`), errors for mixed TCK/TRX or multiple TRX inputs +- Properties/ROI setup is shared with TCK path; count comes from `TRX::count_trx()` instead of `Reader<>` +- `-mask` uses `TrxStream` instead of `subset_streamlines`: a bare `trx::TrxStream` is created (no affine/dimensions — TRX coordinates are already RAS+, the same as TCK, so `VOXEL_TO_RASMM` is never used in MRtrix), cropped geometry is pushed segment-by-segment, and `stream.finalize()` writes the output; a warning is emitted if the input had dps/dpv/groups (they cannot be remapped after vertex-level cropping) +- After the filter pipeline completes, calls `load_trx` → `subset_streamlines(collected_indices)` → `save` +- All existing TCK-mode behaviour is unchanged + +### 3b. `tcktransform` — geometry transform with metadata preservation (DONE) + +Implementation: +- `Loader` class: `Reader` replaced with `unique_ptr>` from `open_tractogram`. Handles TCK and TRX inputs uniformly. +- `Writer` class: extended to support TRX output alongside TCK. Constructor detects `is_trx(file)` and either creates a `trx::TrxStream` (TRX path) or `Tractography::Writer` (TCK path). TrxStream is finalised in the destructor with `finalize(path, TrxSaveOptions{})`. `operator()` converts `Streamline` to `vector>` as required by `push_streamline()`. +- `Warper::pos()` promoted from `protected` to `public` so `run()` can call it directly for the in-place TRX path. +- `run()` branches on input/output format: + +**TRX→TRX in-place path**: detects `is_trx(arg[0]) && is_trx(arg[2])`, loads the full TrxFile with `load_trx()`, iterates the flat `streamlines->_data` position matrix directly (same access pattern as TRXReader), calls `warper.pos()` per vertex and writes back into `_data`. Vertices outside the warp field (where `pos()` returns NaN) are left at their original coordinates with a WARN. After the loop, `trx->save(argument[2])` writes the output — because we modified the in-memory TrxFile, **all metadata (dps, dpv, groups, dpg) is preserved automatically with no extra infrastructure**. + +**Stream path (all other combinations)**: TCK→TCK, TCK→TRX, TRX→TCK. Uses `Loader` + `Warper` + `Writer` via `Thread::run_ordered_queue`. TRX output in this path carries no metadata from a TCK input (there is none), and no metadata from a TRX input (use the TRX→TRX path instead if metadata preservation matters). + +**Package-level observation**: The in-place approach requires direct access to `trx->streamlines->_data(v, 0/1/2)` and `_offsets(s, 0)` — the same pattern used in `TRXReader`. A helper `for_each_vertex(trx, fn)` in `trx_utils.h` that abstracts this access pattern would make future commands that need to iterate or modify TRX positions (e.g., `tckresample`) cleaner and less coupled to trx-cpp internals. + +### 3c. `tckresample` — vertex count change (DONE) + +**Effort**: Medium. **Value**: Medium. + +Current flow: resamples each streamline to a new vertex set via one of several strategies (upsample ratio, downsample ratio, fixed point count, fixed step size, endpoints only, arc). Pure streaming: `Reader` → `Worker` (resampler) → `Receiver`/`Writer`. + +**What changes and why**: + +Because resampling changes vertex count, TRX→TRX cannot use the tcktransform in-place approach. Geometry must be streamed through `TrxStream` (same pattern as the tcktransform stream path). Streamline count is unchanged, so dps and groups can be preserved by copying them from the source TRX after the stream completes. + +**Command-level changes** (all small): +- Input arg: add `.type_directory_in()` +- `run()`: replace `Reader` with `open_tractogram` (2-arg, no weights here) +- `Receiver`: detect TRX output, use `TrxStream` in place of `Writer` (same pattern as tcktransform `Writer` class). After `TrxStream` finalises, copy sidecar data. + +**Metadata policy**: +- **dps** (per-streamline): streamline count unchanged → copy all fields from source TRX to output TRX via `append_dps` after streaming +- **groups** (per-streamline index lists): streamline indices unchanged → copy all groups from source TRX to output TRX +- **dpg** (per-group scalars): no geometry dependency → copy +- **dpv** (per-vertex): vertex count changes → **discard with WARN** if any dpv fields exist + +**Note on dpv discard**: `tckresample` has no TSF resampling capability — the `Resampling::Base` interface is `operator()(const Streamline<>&, Streamline<>&)`, geometry-only. There is no scalar sidecar resampling anywhere in the resampling subsystem, and no existing mechanism in the TCK ecosystem (TSF files are simply incompatible with a resampled track file). Discarding dpv is therefore parity with the TCK+TSF workflow, not a regression. A future `tckresample -tsf_in/-tsf_out` feature would need to extend `Resampling::Base` to also resample a scalar vector alongside each streamline; the same extension would then make dpv resampling natural. + +**Package-level requirement — `copy_trx_sidecar_data(src_path, dst_path, bool include_dpv)`**: + +This utility does not yet exist in `trx_utils.h`. It would: +1. Load source TRX with `load_trx()` +2. For each dps field: call `append_dps(dst_path, name, values)` +3. For each group: append the group (streamline index list) to dst TRX — requires exposing a `append_group(dst_path, name, indices)` utility (analogous to `append_dps` but for groups) +4. For each dpg field on copied groups: append alongside the group +5. If `include_dpv == true`: also copy dpv fields (not used by tckresample) +6. Warn if any dpv fields were present but not copied + +Both `copy_trx_sidecar_data` and `append_group` have been added to `trx_utils.h`. The tckresample implementation emits a WARN listing the count of dpv fields that will be discarded, then calls `copy_trx_sidecar_data(in, out, false)` after the pipeline to copy dps and groups. + +### 3d. `tckmap` — consume dps/dpv for weighted imaging (DONE) + +`tckmap` now accepts TRX input transparently. No new CLI options were needed. + +Implementation: +- `Reader file(argument[0], properties)` replaced by: + ```cpp + auto wt_opt = get_options("tck_weights_in"); + const std::string weight_src = wt_opt.empty() ? "" : std::string(wt_opt[0][0]); + auto reader = open_tractogram(argument[0], properties, weight_src); + ``` + The 3-arg `open_tractogram` (from `trx_utils.h`) dispatches: TCK → `Reader` (handles weights itself); TRX → `TRXReader` with `resolve_dps_weights` applied at construction, injecting `tck.weight` during iteration. +- `TrackLoader loader(file, num_tracks)` → `TrackLoader loader(*reader, num_tracks)` — already works via the `loader.h` `ReaderInterface&` fix. +- `generate_header(header, argument[0], voxel_size)` — the string-path overload was already fixed in `mapping.cpp` to open its own reader internally, so the caller's `reader` is never consumed by the header-generation pass. +- `determine_upsample_ratio(header, properties, ratio)` naturally returns 1 for TRX (no step size in properties). No branching needed. +- `#include "dwi/tractography/file.h"` removed (no longer directly used). + +Transparent weight resolution: `-tck_weights_in weights` works identically whether `weights` is an external text file path or a TRX dps field name — `resolve_dps_weights` handles both cases. + + +## Phase 4: mrview Integration + +This is the largest effort but also the most user-visible payoff. + +### 4a. Basic TRX loading and group panel (DONE) + +TRX loading already works via `TRXReader` in `Tractogram::load_tracks()`. + +**Group panel** (`TrackGroupOptions` widget, new files `track_group_options.h/.cpp`): + +A `QGroupBox` ("TRX Groups") shown below the scalar file options whenever a TRX tractogram with groups is selected. The panel stays hidden for TCK files and TRX files with no groups. + +UI layout: +- **Multi-group** combobox: "First match" / "Last match" (controls which group's color wins when a streamline belongs to multiple groups) +- **Scrollable group list** (max 160 px, ~8 rows): one row per group — checkbox (name + streamline count) + 20×20 color swatch button +- **"Show ungrouped"** checkbox: whether to render streamlines that belong to no group + +Data model (stored in `Tractogram`): +- `GroupState` struct: `visible`, `color` (Eigen::Vector3f), `count` +- `group_states`: `std::map` — keyed by group name +- `group_order`: `std::vector` — determines color priority for overlapping groups +- `show_ungrouped`: bool +- `group_multi_policy`: `GroupMultiPolicy` enum (`FirstMatch` / `LastMatch`) + +The colour buffer (GPU) is rebuilt on any change via `Tractogram::reload_group_colours()`, which: +1. Initialises `scolours[ns]` to `HIDDEN = (-1,-1,-1)` (a sentinel never produced by valid RGB) +2. For ungrouped streamlines (not in any group), assigns `UNGROUPED = (0.3,0.3,0.3)` when `show_ungrouped` is true +3. Iterates `group_order` (forward for FirstMatch, reverse for LastMatch); only visible groups participate; a `claimed[]` bool prevents later groups from overwriting earlier winners +4. Uploads per-streamline colours in exactly the same way as `load_end_colours()` + +The fragment shader discards fragments with `colour.r < 0.0` (the sentinel) when in Group mode, so hidden-group streamlines produce no pixels without needing a second render pass. + +`Tractogram::init_group_states()` is called lazily (first time the panel is shown or "TRX Groups" colour mode is selected) and assigns palette colours in order. + +`Tractography::update_scalar_options()` calls `group_options->set_tractogram(t)` / `update_UI()` alongside `scalar_file_options`, so the panel appears/disappears with selection changes. + +### 4b. DPS/DPV coloring (DONE) + +Coloring modes `ScalarFile` now covers TRX embedded fields via the "TRX field…" button in `TrackScalarFileOptions`. Both dps (per-streamline) and dpv (per-vertex) fields are accessible. + +### 4c. Future: dpg colors, group reordering drag-and-drop, dpv threshold source + +- When a TRX file has a `dpg["color"]` field, use those colors as defaults in `init_group_states()` instead of the palette +- Add drag-to-reorder rows in the group panel to change `group_order` interactively +- The threshold combobox currently only supports "Separate scalar file" (TSF); a "TRX dpv field" threshold source would allow using an embedded binary mask (e.g. created by `tsfthreshold`) directly as a display threshold without first exporting to TSF + +## Phase 5: Connectivity & Fixel Commands + +### 5a. `tck2fixel` — fixel TDI from TRX input (DONE) + +**Effort**: Very small. **Value**: Medium. + +Current flow: opens `Reader` from the tracks argument, creates `TrackLoader` + `TrackMapperBase` + `TrackProcessor`, accumulates per-fixel streamline counts. + +**Command-level changes**: +- Input arg already has `.type_tracks_in()` — add `.type_directory_in()` +- Add `#include "dwi/tractography/trx_utils.h"` and `using namespace TRX` +- Replace `DWI::Tractography::Reader track_file(track_filename, properties)` with `auto reader = open_tractogram(track_filename, properties)` (2-arg, no weights) +- Change `TrackLoader loader(track_file, num_tracks, ...)` → `TrackLoader loader(*reader, num_tracks, ...)` +- Remove `track_file.close()` (RAII via unique_ptr) +- `determine_upsample_ratio(index_header, properties, 0.333f)` is already TRX-safe (returns 1 when no step size in properties) + +**No new CLI options needed.** TRX groups are not consumed here — the command produces a fixel TDI regardless of group structure. + +### 5b. `tckdfc` — TRX input for dynamic functional connectivity (DONE) + +**Effort**: Small-Medium (4 Reader instantiation sites, all mechanical). **Value**: Medium. + +Current flow: complex multi-pass algorithm that re-opens the track file for each fMRI timepoint. A properties-only probe is done in a scope block first, then the static path makes one pass, and the dynamic path makes up to N+1 passes (TDI counts + one per timepoint). + +**Pre-existing bug to fix while here**: The tracks argument uses `.type_file_in()` instead of `.type_tracks_in()`. Change to `.type_tracks_in().type_directory_in()`. + +**All 6 Reader call sites** follow the same pattern — replace each with `auto reader = open_tractogram(tck_path, properties)` and `TrackLoader loader(*reader, ...)`: +1. Properties probe: `{ Tractography::Reader tck_file(tck_path, properties); }` → `{ auto r = open_tractogram(tck_path, properties); }` +2. Static path reader + TrackLoader +3. Dynamic path count-pass reader + TrackLoader +4. Dynamic path per-timepoint reader + TrackLoader (called inside a for loop — N times) + +`generate_header(header, argument[0], voxel_size)` and `determine_upsample_ratio(header, properties, ...)` are already TRX-safe. + +**No new CLI options needed.** tckdfc doesn't use per-streamline weights or sidecar data — it only needs geometry. + +**Multi-pass note**: Each `open_tractogram` re-loads the TRX (re-opens the zip or re-mmaps the directory). For large TRX files in dynamic mode this may be slower than TCK re-opening, but the bottleneck is the fMRI correlation computation not the file open. No caching infrastructure is needed. + +### 5c. `fixelconnectivity` — fixel-fixel connectivity matrix from TRX input (DONE) + +**Effort**: Small-Medium. **Value**: High (dps weights from TRX eliminates need for separate SIFT2 weight files). + +Current flow: delegates to `Fixel::Matrix::generate_unweighted(argument[1], ...)` or `generate_weighted(argument[1], ...)`. Both functions are implemented via the `FIXEL_MATRIX_GENERATE_SHARED` macro in `fixel/matrix.cpp`: + +```cpp +#define FIXEL_MATRIX_GENERATE_SHARED \ + ... \ + DWI::Tractography::Reader track_file(track_filename, properties); \ + ... \ + DWI::Tractography::Mapping::TrackLoader loader(track_file, num_tracks, ...); +``` + +**Library change required** (`fixel/matrix.cpp`): +1. Add `#include "dwi/tractography/trx_utils.h"` and `using namespace MR::DWI::Tractography::TRX` +2. Update `FIXEL_MATRIX_GENERATE_SHARED` to use `open_tractogram`: + ```cpp + auto wt_opt = MR::App::get_options("tck_weights_in"); + const std::string weight_src = wt_opt.empty() ? "" : std::string(wt_opt[0][0]); + auto track_reader = open_tractogram(track_filename, properties, weight_src); + const uint32_t num_tracks = ...; + DWI::Tractography::Mapping::TrackLoader loader(*track_reader, num_tracks, ...); + ``` + The weight source is resolved from the option at this level because the macro is the only place the reader is constructed. For the unweighted case `weight_src` will be empty and `open_tractogram` 3-arg passes through to the 2-arg version. + +**Command-level change**: add `.type_directory_in()` to the `argument[1]` tracks argument. `TrackWeightsInOption` already uses `type_text()`. + +**Why the macro is the right place**: `generate_unweighted` and `generate_weighted` differ only in the matrix type; the reader construction is identical. Updating the macro updates both in one place. Alternatively the macro could be replaced with a template helper function, which would be cleaner but is a larger refactor. + +### 5d. `afdconnectivity` — AFD-based connectivity from TRX input (DONE) + +**Effort**: Small. **Value**: Medium. + +Current flow: `AFDConnectivity` (extends `SIFT::ModelBase`) does two things with track files: +1. **`map_streamlines(wbft_path)`** — called in constructor when `-wbft` is provided; this calls `ModelBase::map_streamlines` which uses `Reader<>` internally +2. **`AFDConnectivity::get(path)`** — opens `Reader` directly and creates `TrackLoader` + +**Important discovery**: `SIFT::Model::map_streamlines` already overrides the base class with TRX support (it has `if (TRX::is_trx(path))` branch). However, `AFDConnectivity` inherits from `ModelBase`, not `Model`, so it uses the unpatched base class version. + +**Changes needed**: + +1. **Command** (`afdconnectivity.cpp`): + - Add `.type_directory_in()` to `argument[1]` (pathway tracks, already has `.type_tracks_in()`) + - Add `.type_directory_in()` to the `-wbft` option argument (already has `.type_tracks_in()`) + - In `AFDConnectivity::get(path)`: replace `Reader reader(path, properties)` with `auto reader = open_tractogram(path, properties)`; change `TrackLoader loader(reader, ...)` → `TrackLoader loader(*reader, ...)` + - Add `#include "dwi/tractography/trx_utils.h"` and `using namespace TRX` + +2. **Library** (`SIFT/model_base.h`, `map_streamlines` template method): + - Update to use `open_tractogram` instead of `Reader<>` — same change as done in `SIFT::Model::map_streamlines` + - OR: Since `Model::map_streamlines` already handles TRX, consider whether `AFDConnectivity` could inherit from `Model` instead of `ModelBase`. This would be a larger refactor but would avoid the duplication between `Model::map_streamlines` and `ModelBase::map_streamlines`. + - Recommended minimal fix: update `ModelBase::map_streamlines` to use `open_tractogram`, removing the duplication now present between base and derived class. + +`determine_upsample_ratio(fod_buffer, tck_path, 0.1)` uses the string-path overload which is already TRX-safe. + +## Phase 6: Convenience & Polish + +- **`tckconvert`**: Already done, but add TrxStream path to avoid the pre-counting second pass for non-TRX→TRX conversions. +- **TSF utilities** (`tsfinfo`, `tsfvalidate`, `tsfdivide`, `tsfmult`, `tsfsmooth`, `tsfthreshold`): Can now operate on TRX dpv entries (DONE). Each command accepts TRX files as input/output alongside `.tsf` files, using `-field_in`/`-field_out`/`-field1`/`-field2` options to name the dpv fields. + +## Complete Command Inventory + +### High Value — Produce/consume sidecar data mapping to TRX dps/dpv/groups + +| Command | Current sidecar data | TRX mapping | Status | +|---------|---------------------|-------------|--------| +| **tckinfo** | — | Print TRX metadata | DONE | +| **tckconvert** | DPS/DPV/DPG passthrough | Full TRX I/O | DONE | +| **tcksift2** | Text weights file | Weights → dps | DONE | +| **fixel2tsf** | TSF (per-vertex) | Fixel values → dpv | DONE | +| **tcksample** | TSF or per-streamline stats | Per-vertex → dpv, stats → dps | DONE | +| **trxlabel** | — (new command) | Assigns streamlines to groups by parcellation atlas | DONE | +| **trx2connectome** | — (new command) | Builds connectome matrix from TRX groups | DONE | +| **connectome2tck** | Reads assignments, extracts per-edge TCKs | Derive assignments from TRX groups via `"-"` arg; output stays TCK | DONE | +| **tcksift** | Filtered TCK + optional selection text | TRX-in + TRX-out via `subset_streamlines`; dps/dpv/groups preserved | DONE | +| **tckstats** | Per-streamline lengths (optional dump) | Lengths → dps | DONE | + +### Medium Value — Geometry I/O with metadata handling + +| Command | Notes | Status | +|---------|-------|--------| +| **tckedit** | Filter/concatenate with metadata-aware subsetting of dps/dpv/groups | DONE | +| **tckmap** | Consume dps/dpv for weighted track-density imaging | DONE | +| **tcktransform** | Transform geometry, preserve all metadata unchanged | DONE | +| **tckresample** | Preserve dps/groups; dpv needs interpolation or discard | DONE | +| **tckgen** | TrxStream writer for direct TRX generation | DONE | +| **tck2fixel** | Accept TRX input; geometry-only (no group awareness) | DONE | +| **tckdfc** | Accept TRX input; multi-pass re-open pattern (4 sites) | DONE | +| **fixelconnectivity** | Accept TRX input; dps weights via `resolve_dps_weights` in library macro | DONE | +| **afdconnectivity** | Accept TRX input; fix `ModelBase::map_streamlines` | DONE | + +### Low Value — TSF utilities and inspection + +| Command | Notes | Status | +|---------|-------|--------| +| **tsfinfo** | Lists TRX dpv fields; `-field` for -ascii export | DONE | +| **tsfvalidate** | Validates TRX dpv vertex count with `-field` | DONE | +| **tsfdivide/tsfmult** | Arithmetic on dpv entries via `-field1`/`-field2`/`-field_out` | DONE | +| **tsfsmooth** | Smooth dpv along streamlines via `-field_in`/`-field_out` | DONE | +| **tsfthreshold** | Threshold dpv to binary mask via `-field_in`/`-field_out` | DONE | +| **tckglobal** | Generation only; output format swap | TODO | + +## Recommended Implementation Order + +| Priority | Task | What | Status | +|----------|------|------|--------| +| 1 | **Foundation** | `trx_utils.h` shared utilities | DONE | +| 2 | **Infrastructure** | `loader.h` accepts `ReaderInterface&`; `mapping.cpp` `generate_header` overload | DONE | +| 3 | **Foundation** | `open_tractogram()`, `resolve_dps_weights()`, `resolve_dpv_scalars()` in `trx_utils.h` | DONE | +| 4 | **tckinfo** | Print TRX metadata | DONE | +| 5 | **tcksift2** | `-trx_dps` for weights | DONE | +| 6 | **fixel2tsf** | `-trx_dpv` for fixel data | DONE | +| 7 | **tcksample** | `-trx_dpv` and `-trx_dps` for sampled values | DONE | +| 8 | **tcksift** | TRX-aware subsetting via `subset_streamlines` | DONE | +| 9 | **tckedit** | Metadata-aware TRX filtering | DONE | +| 10 | **tckmap** | `open_tractogram` + `resolve_dps_weights`; no new CLI args | DONE | +| 11 | **trxlabel + trx2connectome** | new TRX-native connectome pipeline | DONE | +| 12 | **connectome2tck** | Read groups/dps from TRX | DONE | +| 13 | **tckstats** | lengths as dps via `append_dps` | DONE | +| 14 | **tckgen** | TrxStream writer | DONE | +| 15 | **tcktransform** | Geometry transform + metadata preservation | DONE | +| 16 | **mrview basic** | Load TRX, group panel with per-group visibility/color | DONE | +| 17 | **mrview advanced** | dpg-driven default colors, drag-to-reorder group priority | TODO | + +## Testing Strategy + +### How MRtrix tests work + +Tests live in `testing/binaries/tests/{command}/` as plain bash scripts. Each script runs with its working directory set to the root of the binary test data repository (`mrtrix3/test_data`, fetched at CMake time via ExternalProject_Add). Temporary output files must be prefixed `tmp` — CMake cleans them up automatically. The key comparison tools are: + +- `testing_diff_tck tck1 tck2 [-distance mm] [-maxfail n] [-unordered]` — symmetric Hausdorff distance between streamline pairs +- `testing_diff_tsf tsf1 tsf2 [-frac f] [-abs a]` — per-vertex scalar file comparison +- `testing_diff_matrix mat1 mat2 [-frac f] [-abs a]` — CSV / matrix comparison + +A new command `testing_diff_trx` does not yet exist; see below. + +### Core challenge: test data lives in a separate repo + +New test data files (`.trx` inputs or reference outputs) must be committed to the `mrtrix3/test_data` repository, and the `GIT_TAG` in `testing/CMakeLists.txt` must be updated to point to the new commit. Until then, any test that requires those files will fail during CI. + +**The workaround that avoids this for most tests**: construct TRX inputs inside the test script itself using `tckconvert` on existing `.tck` data (already in the test data repo), then verify output by converting back to `.tck` with `tckconvert` and running `testing_diff_tck`. This sidesteps the need for pre-generated `.trx` reference files in many cases. + +### What needs to go into the test data repo + +The following files need to be added to `mrtrix3/test_data` before the corresponding tests can be written: + +| File | Generated by | Used by | +|------|-------------|---------| +| `SIFT_phantom/tracks.trx` | `tckconvert SIFT_phantom/tracks.tck SIFT_phantom/tracks.trx` | tcksift, tcksift2, tckedit, tckgen TRX tests | +| `tcksample/fa_trx_dpv.trx` | reference dpv output | tcksample `-trx_dpv` test | +| `tcksample/fa_trx_dps.trx` | reference dps output | tcksample `-trx_dps` test | +| `fixel2tsf/tracks.trx` | `tckconvert tracks.tck fixel2tsf/tracks.trx` + append dpv | fixel2tsf `-trx_dpv` test | + +For now, the simplest path is to **generate these files once manually** (in a local build), commit them to a branch of `test_data`, and then update `GIT_TAG` in `testing/CMakeLists.txt`. + +### Per-command test plan + +#### `tckconvert` (tests already exist) +- `trx_read`: TRX zip → TCK, compare with `testing_diff_tck` ✓ +- `trx_uncompressed_read`: TRX directory → TCK ✓ +- `trx_write_dps_float{16,32,64}`: TCK → TRX with dps, verify file presence ✓ +- **New**: `trx_roundtrip` — TCK → TRX → TCK, compare with `testing_diff_tck -distance 1e-5` +- **New**: `trx_float16_roundtrip` — TCK → TRX float16 → TCK, compare with `testing_diff_tck -distance 0.1` (looser tolerance for float16 quantization) + +#### `tckinfo` +- **New**: `trx_metadata` — `tckinfo gs.trx` and check exit code + presence of expected header strings (NB_STREAMLINES, NB_VERTICES) in output via pipe to `grep` + +#### `tckgen` +- **New**: `trx_output` — generate 100 streamlines to `tmp.trx`, convert back to TCK and count; verify count matches (no geometry comparison since probabilistic) + ```bash + tckgen SIFT_phantom/fods.mif -algo ifod2 -seed_image SIFT_phantom/mask.mif \ + -mask SIFT_phantom/mask.mif -minlength 4 -select 100 tmp.trx -force + tckconvert tmp.trx tmp_check.tck -force + tckinfo tmp_check.tck | grep -q "count: 100" + ``` +- **New**: `trx_float16` — same as above but with `-trx_float16`; convert to TCK and compare geometry with normal float32 output using `testing_diff_tck -distance 0.1` + +#### `tckedit` +- **New**: `trx_length` — create TRX from existing `tckedit/in.tck`, filter by length, convert result to TCK, compare with existing `tckedit/upper.tck` using `testing_diff_tck`: + ```bash + tckconvert tckedit/in.tck tmp_in.trx -force + tckedit tmp_in.trx -minlength 10 tmp_out.trx -force + tckconvert tmp_out.trx tmp_out.tck -force + testing_diff_tck tmp_out.tck tckedit/upper.tck + ``` +- **New**: `trx_preserves_dps` — round-trip test that dps fields survive `tckedit`: create TRX with dps, filter, verify dps count matches streamline count in output + +#### `tcksift` +- **New**: `trx_subset` — run SIFT on `SIFT_phantom/tracks.trx`, get TRX output; convert both outputs (TCK-mode and TRX-mode) to a common format and verify same streamlines are selected (or compare density maps as in the existing `default` test) + +#### `tcksift2` +- **New**: `trx_dps` — run SIFT2 on `SIFT_phantom/tracks.trx` with `-trx_dps tmp_sift2.trx weights`, verify the `.trx` was created and that extracting the `weights` dps produces values equivalent to the text output `tmp.csv`. Extract dps with `tckconvert` and compare with `testing_diff_matrix`. + +#### `fixel2tsf` +- **New**: `trx_dpv` — run `fixel2tsf` with `-trx_dpv tracks.trx fixel_afd` alongside the normal TSF output; extract the dpv as a TSF with `tckconvert`, compare with `testing_diff_tsf` + +#### `tcksample` +- **New**: `trx_dpv` — sample with no `-stat_tck` (per-vertex mode), append to TRX with `-trx_dpv`; extract dpv with `tckconvert` to a TSF, compare with existing reference `tcksample/*.tsf` +- **New**: `trx_dps_mean` — sample with `-stat_tck mean`, append to TRX with `-trx_dps`; extract dps to CSV with `tckconvert`, compare with `tcksample/mean.csv` using `testing_diff_matrix` + +### Verification tools needed + +#### Short term — use `tckconvert` as a shim +`tckconvert` can already extract dps to text (`-extract_weights`) and write TRX directory format. This lets tests verify dps/dpv values by: +1. Appending dps/dpv with the command under test +2. Extracting it as text with `tckconvert -extract_weights` or equivalent +3. Comparing with `testing_diff_matrix` + +#### Longer term — `testing_diff_trx` +A dedicated `testing/tools/testing_diff_trx.cpp` would provide: +- Streamline geometry comparison (delegates to Hausdorff distance logic from `testing_diff_tck`) +- Per-streamline dps field comparison with tolerances +- Per-vertex dpv field comparison with tolerances +- Group membership comparison + +This is worth writing once the test suite grows beyond 5-6 tests. Until then, the `tckconvert`-shim approach is sufficient. + +### Building mrview on macOS with newer Xcode Command Line Tools + +The default `MacOSX.sdk` symlink in newer Xcode CLI tools versions points to a beta/preview SDK (e.g. `MacOSX26.2.sdk`) that has dropped the `AGL.framework`, which mrview links against. `AGL` still exists in `MacOSX15.sdk`. Set `SDKROOT` at build time to work around this: + +```bash +SDKROOT=/Library/Developer/CommandLineTools/SDKs/MacOSX15.sdk ninja -C build mrview +``` + +This produces deprecation-version warnings (homebrew libs built for macOS 14/15, binary targeting 11.0) but links successfully. + +**Permanent fix** — reconfigure cmake with the explicit sysroot so the flag is baked into build.ninja and `ninja -C build mrview` works without `SDKROOT` each time: + +```bash +cmake -B build -DCMAKE_OSX_SYSROOT=/Library/Developer/CommandLineTools/SDKs/MacOSX15.sdk . +ninja -C build mrview +``` + +This cmake invocation was used in the session that added the group panel, so the build directory is already configured with `MacOSX15.sdk`. + +**Symptom of the wrong SDK**: compilation fails immediately with `implicit instantiation of undefined template 'std::basic_ifstream'` in `cpp/core/file/matrix.h`, cascading into "too many errors emitted". This is not a code bug — it means `SDKROOT` is pointing at the wrong (beta) SDK. + +### Adding new Qt widget files (Q_OBJECT / AUTOMOC) + +`cpp/gui/CMakeLists.txt` uses `file(GLOB_RECURSE GUI_SOURCES *.h *.cpp)` so new `.cpp` files are picked up automatically — but only after cmake re-runs. Qt's AUTOMOC generates `moc_*.cpp` files by scanning for `Q_OBJECT` in headers; this scan also only runs during cmake configuration. + +**Problem**: after adding a new `.h` with `Q_OBJECT`, the MOC file is silently skipped if cmake's autogen cache is stale. The linker then fails with: + +``` +vtable for MR::GUI::MRView::Tool::YourClass referenced from ... +NOTE: a missing vtable usually means the first non-inline virtual member function has no definition. +``` + +This looks like a code error but is actually a cmake/MOC cache problem. + +**Fix**: delete the autogen directory and reconfigure: + +```bash +rm -rf build/cpp/gui/mrtrix-gui_autogen +cmake -B build . +ninja -C build mrview +``` + +The `rm` forces cmake to re-run the full AUTOMOC scan on the next configure. A plain `cmake -B build .` without deleting the autogen directory is not sufficient — cmake considers the autogen outputs up-to-date even when new `Q_OBJECT` headers have been added. + +### Current test data location + +Test data lives at `https://github.com/mattcieslak/test_data`, branch `add-trx-test-data` (commit `552958e`). The `testing/CMakeLists.txt` `GIT_REPOSITORY` and `GIT_TAG` are set to point there. Build with tests enabled: + +```bash +cmake -B build -DMRTRIX_BUILD_TESTS=ON . +cmake --build build --target testing_diff_tck testing_diff_tsf testing_diff_matrix testing_diff_image +``` + +### Workflow for adding more test data + +1. Generate the reference file in a local build (working directory = test data root): + ```bash + tckconvert SIFT_phantom/tracks.tck SIFT_phantom/tracks.trx -force + ``` +2. Commit to `add-trx-test-data` branch of the fork and push +3. Update `GIT_TAG` in `testing/CMakeLists.txt` to the new commit hash +4. Add the bash test script under `testing/binaries/tests/{command}/trx_*` +5. The test is registered automatically via CMake's directory scan — no CMakeLists changes needed + +### Implemented tests (all passing) + +| Test | What it verifies | +|------|-----------------| +| `tckinfo/trx_metadata` | `tckinfo` prints streamline count, dps, dpv fields for TRX input | +| `tckgen/trx_output` | `tckgen` writes valid TRX with correct streamline count | +| `tckgen/trx_float16` | `-trx_float16` produces a smaller file than float32 default | +| `tckedit/trx_length` | `-minlength`/`-maxlength` filtering of TRX input matches TCK reference | +| `tckedit/trx_preserves_metadata` | dps and dpv fields survive tckedit subsetting | +| `tcksift/trx_subset` | SIFT on TRX input produces balanced density maps (same as TCK path) | +| `tcksift2/trx_dps` | SIFT2 weights appended as dps to TRX; field visible in `tckinfo` | +| `fixel2tsf/trx_dpv` | fixel2tsf dpv values match TSF output; field visible in `tckinfo` | +| `tcksample/trx_dps_mean` | Per-streamline mean stats appended as dps match CSV reference | +| `tcksample/trx_dpv` | Per-vertex sampled values appended as dpv match TSF reference | +| `trxlabel/default` | 4 groups created after labeling SIFT phantom; group names match node indices | +| `trxlabel/multiple_atlases` | Same atlas twice with prefixes a/b → 8 groups in output TRX | +| `trx2connectome/default` | Full trxlabel + trx2connectome pipeline matches `tck2connectome/out.csv` | +| `trx2connectome/forward_search` | With `-assignment_forward_search 5`, output matches tck2connectome reference | +| `trx2connectome/group_prefix` | `-group_prefix` filters correctly; stripped names appear in `-out_node_names` | +| `tsfinfo/trx_dpv` | `tsfinfo` lists dpv fields and vertex counts for TRX input | +| `tsfvalidate/trx_dpv` | `tsfvalidate -field` validates dpv vertex count matches TRX offsets | +| `tsfthreshold/trx_dpv` | `tsfthreshold` adds binary mask dpv field; TSF extraction has correct count | +| `tsfsmooth/trx_dpv` | `tsfsmooth` appends smoothed dpv field; both fields visible in tsfinfo | +| `tsfdivide/trx_dpv` | `tsfdivide` appends ratio dpv field; TSF path regression check | +| `tsfmult/trx_dpv` | `tsfmult` appends product dpv field; TSF path regression check | +| `connectome2tck/trx_default` | `trxlabel` + `connectome2tck labeled.trx - edge-`; per-edge TCK matches TCK + text-file path | +| `connectome2tck/trx_forward_search` | Same with `-assignment_forward_search 5` on both paths | +| `connectome2tck/trx_group_prefix` | Multi-atlas TRX; `-group_prefix dk` extracts same streamlines as single-atlas labeled TRX | +| `tckmap/trx_tdi` | TRX and TCK inputs produce identical TDI (within ±1.5 streamlines/voxel) | +| `tckmap/trx_dps_weights` | Weights from TRX dps field produce same weighted TDI as external text weights file | +| `tckmap/trx_length_contrast` | Length-weighted TDI matches between TRX and TCK inputs | +| `tckstats/trx_stats` | TRX and TCK inputs report identical count/mean/std/min/max | +| `tckstats/trx_dps` | Per-streamline lengths appended as dps field; field visible in `tckinfo` | +| `tcktransform/trx_geometry` | TRX input produces identical warped geometry as TCK input (vs. reference) | +| `tcktransform/trx_preserves_metadata` | TRX→TRX in-place path preserves dps fields after warping | + +## Pending Package-Level Infrastructure + +These changes are not in any specific command — they are improvements to shared library code or `trx_utils.h` that would unblock or clean up multiple commands. + +### P1. `copy_trx_sidecar_data(src_path, dst_path, bool include_dpv)` in `trx_utils.h` + +**Needed by**: `tckresample` (TRX→TRX, dps/groups/dpg preservation after streaming). +**Also useful for**: any future geometry-modifying command that streams through `TrxStream` and needs to carry metadata forward. + +Implementation sketch: +```cpp +inline void copy_trx_sidecar_data(std::string_view src_path, + std::string_view dst_path, + bool include_dpv = false) { + auto src = load_trx(src_path); + // dps + for (const auto &[name, arr] : src->data_per_streamline) { + std::vector vals(arr->_data.data(), arr->_data.data() + arr->_data.size()); + append_dps(dst_path, name, vals); + } + // groups (requires append_group helper — see P2) + for (const auto &[name, group] : src->groups) { + std::vector indices = /* extract from group->_data */; + append_group(dst_path, name, indices); + // TODO: also copy dpg fields for this group + } + // dpv (only if vertex count is unchanged) + if (include_dpv) { + for (const auto &[name, arr] : src->data_per_vertex) { + std::vector vals(arr->_data.data(), arr->_data.data() + arr->_data.size()); + append_dpv(dst_path, name, vals); + } + } +} +``` + +### P2. `append_group(trx_path, name, indices)` in `trx_utils.h` + +**Needed by**: `copy_trx_sidecar_data` (P1); also useful standalone for commands that need to add a single named group. + +`trxlabel` already calls `trx::append_groups_to_zip/directory` with a full groups map. A simpler per-group wrapper would allow incremental addition and simplify `copy_trx_sidecar_data`. + +### P3. `ModelBase::map_streamlines` TRX support + +**Needed by**: `afdconnectivity` (uses `ModelBase`, not `Model`). + +`SIFT::Model::map_streamlines` (in `model.h`) already has TRX support via `if (TRX::is_trx(path))`. `ModelBase::map_streamlines` (in `model_base.h`) does not. The minimal fix is to replace `Reader<>` with `open_tractogram` in `ModelBase::map_streamlines` — removing the duplication between base and derived class implementations. + +### P4. `FIXEL_MATRIX_GENERATE_SHARED` macro update in `fixel/matrix.cpp` + +**Needed by**: `fixelconnectivity`. + +The macro expands `Reader track_file(...)` + `TrackLoader loader(track_file, ...)`. Replace with `open_tractogram(track_filename, properties, weight_src)` + `TrackLoader loader(*track_reader, ...)`. Adds weight-source lookup from `get_options("tck_weights_in")` inside the macro so both `generate_unweighted` and `generate_weighted` benefit. Since the macro is in a `.cpp` file and is private to `fixel/matrix.cpp`, no header changes are needed. + +### P5. `for_each_vertex(trx, fn)` helper in `trx_utils.h` + +**Needed by**: `tckresample` (would benefit), future vertex-modifying commands. + +Already noted in the tcktransform section. Abstracts `_data(v,0/1/2)` + `_offsets(s,0)` access pattern. Would be used by tckresample if it ever needs in-place vertex resampling (unlikely — vertex count changes so streaming is required), but would help if any command needs to read all vertex positions for inspection. + +## Key Design Decisions + +1. **TRX coordinates are RAS+, same as TCK**: MRtrix treats TRX streamline coordinates as RAS+ world-space coordinates, identical to the TCK convention. The `VOXEL_TO_RASMM` field in the TRX header is never used for coordinate transformation in any MRtrix command — it is printed by `tckinfo` for informational purposes only. No affine is ever applied when reading or writing TRX geometry. + +3. **Append vs. rewrite**: trx-cpp supports `append_dps_to_zip()` for adding data to existing TRX files without rewriting. This is efficient but means commands need to decide: create new TRX, or modify existing? If the same file is listed as input and output, then the new data (dps, dpv, groups) should just be appended. + +4. **No region-pair groups for tck2connectome**: Creating groups for each (node_A, node_B) pair would produce O(n²) groups and is impractical for large parcellations. Instead, each node becomes a trx group. + +5. **Thread safety for TrxStream**: For `tckgen`, confirm that `TrxStream::push_streamline()` is called only from the writer thread. If multi-threaded push is needed, a mutex wrapper or per-thread buffering would be required. + +6. **When input is TCK but user wants TRX output**: Some commands (like `tcksift2`) don't normally write tracks at all — they write sidecar data. Adding TRX output means they'd also need to copy/reference the geometry. The cleanest approach: require the user to first convert to TRX, then use the `-trx_dps`/`-trx_dpv` options to add metadata. + +8. **Sidecar resolution — embedded-first, external-fallback**: For commands that accept external sidecar files (weights, TSF scalars), the option argument doubles as either a file path or a TRX field name. Helper functions in `trx_utils.h` (`resolve_dps_weights`, `resolve_dpv_scalars`) handle the dispatch: check if the string is a path to an existing file first; if not, treat it as a dps/dpv field name in the TRX input. This eliminates the need for new `-trx_dps_*` CLI options and keeps command code unchanged. + +9. **Positions dtype on read**: `load_trx()` always returns `TrxFile` with correct float32 coordinates regardless of the on-disk dtype. Float16/float64 files trigger a double-load: once as float32 (for structure/dps/dpv/groups) and once with the native dtype (for positions). A `WARN` is emitted. All MRtrix commands transparently handle float16 TRX inputs. + +10. **Positions dtype on write**: `tckgen` defaults to float32 TRX output (`-trx_float16` opts in to float16). The dtype is plumbed via `properties["trx_positions_dtype"]` → `WriteKernel::create_writer()` → `TRXWriterAdapter` → `TrxStream(dtype)`. Other write paths (`TRXWriter` in `trx_utils.h`, `TrxStream` in `tckedit`) currently always write float32; float16 support can be added per-command using the same property mechanism. + +7. **DPV and geometry-modifying commands**: Commands that change vertex count (tckresample) invalidate dpv data. Policy: discard dpv with a warning unless interpolation is explicitly requested. Commands that only transform coordinates (tcktransform) preserve dpv unchanged.