Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

DWI metadata handling fixes #2917

Draft
wants to merge 9 commits into
base: dev
Choose a base branch
from
Draft

DWI metadata handling fixes #2917

wants to merge 9 commits into from

Conversation

Lestropie
Copy link
Member

@Lestropie Lestropie commented May 29, 2024

Closes #2477.

Work toward #2526.

This one has taken a lot more of my time than I would have liked. But it needed to be properly resolved, not just to fix issues for those disabling internal transform realignment but also because downstream work on BIDS BEP016 Diffusion models is dependent on these format definitions and conversion operations being robust.

The core mistake here was:

  • There exists a function to indicate, when a saved image is NIfTI, what the axis permutations / flips and eventual transform will be.
  • This was being erroneously used to indicate what transformations had been performed on load of an image.

I've done a bunch of refactoring to store internally more exhaustive information about what (if anything) was performed at the internal header transform realignment phase. In addition to trivialising transformations between on-disk image header and scanner-space, this will also facilitate tracking exactly what differs between on-disk and interpreted versions of an image; not just transform & strides but potentially also metadata that are defined with respect to image axes.

According to my battery of tests, this all yields the correct results:

  • For all possible combinations of acquisition slice and phase encoding
  • For inputs of any format
    (DICOM, NIfTI / JSON / bvec / bval, MRtrix w. external metadata / MRtrix w. header metadata)
    • From mrconvert or dcm2niix
      • With & without transform realignment in the former case
  • For outputs of any format
    (NIfTI / JSON / bvec / bval, MRtrix w. external metadata / MRtrix w. header metadata)
    • With & without transform realignment
    • For any output strides

Questions:

  1. Move core/axes.*?
    From memory these used to be much larger; what's left there now is almost exclusively about transform realignment of spatial axes, which isn't really captured by the file name / namespace.

  2. Move much of dwi/gradient.* into core/metadata/?
    It made sense for me to move phase_encoding.* out of core/ given I needed something similar for slice encoding and those are naturally grouped. But a gradient table is also "metadata that can be stored in an image header or external file(s) that necessitates advanced handling".


  • Back-port to master.
    Ultimately these are bug fixes, so should ideally be in 3.0.5.
    But maybe I'll try to restrict the scope of it by just making requisite changes and preserving a bit of code duplication that the refactoring removed / was done to hopefully facilitate mrconvert: fslgrad and RealignTransform: 0 #2477.

  • transformconvert flirt_import:

    • Use on-disk transform rather than File::NIfTI::axes_on_write()
    • Test
  • meshconvert -transform fs2real:

    • Use on-disk transform rather than File::NIfTI::axes_on_write()
    • Test
  • Check handling of user-specified phase encoding information
    Particularly for dwifslpreproc -pe_dir, there needs to be greater care around interpretation depending on whether the user's configuration does or does not include internal transform realignment

  • Fix diffusion scheme docs page description of bvec format
    Appears to currently omit the left-handed vs. right-handed distinction, and also uses "x, y, z" to refer to image axes.

- New class Axes::Shuffle, which encapsulates both permutations and flips so that these can in combination be returned by functions.
- Move some handling of slice encoding direction transformation to core/metadata/slice_encoding.h.
- Move handling of minor differences in slice timing to core/metadata/slice_encoding.h.
- For many functions relating to phase and slice encoding, operate on key-value dictionaries rather than Headers; this allows manipulation where that information is associated with external files rather than image hedaers.
- DWI::load_bvecs_bvals(): Simplify implementation given availability of original image header on disk.
- For JSON import, do not prioritise image header contents over JSON contents; instead use Header::merge_keyval() function to explicitly flag conflicts.
- For JSON load, ensure that any operations relating to internal header transform realignment are applied only to those contents loaded from the JSON, not anything already stored in the header.
- Always call Header::realign_transform() in order to capture on-disk information; just exit prematurely if do_realign_transform is false.
- Homogenise function interfaces between phase and slice encoding manipulations.
- If writing a JSON alongside a NIfTI, suppress "dw_scheme" from its contents.
Copy link

@github-actions github-actions bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

clang-tidy made some suggestions

There were too many comments to post at once. Showing the first 25 out of 55. Check the log or trigger a new build to see more.

flip[perm[0]] = T(0, perm[0]) < 0.0;
flip[perm[1]] = T(1, perm[1]) < 0.0;
flip[perm[2]] = T(2, perm[2]) < 0.0;
result.flips[result.permutations[0]] = T(0, result.permutations[0]) < 0.0;

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

warning: narrowing conversion from 'value_type' (aka 'unsigned long') to signed type 'Index' (aka 'long') is implementation-defined [bugprone-narrowing-conversions]

  result.flips[result.permutations[0]] = T(0, result.permutations[0]) < 0.0;
                                              ^

flip[perm[1]] = T(1, perm[1]) < 0.0;
flip[perm[2]] = T(2, perm[2]) < 0.0;
result.flips[result.permutations[0]] = T(0, result.permutations[0]) < 0.0;
result.flips[result.permutations[1]] = T(1, result.permutations[1]) < 0.0;

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

warning: narrowing conversion from 'value_type' (aka 'unsigned long') to signed type 'Index' (aka 'long') is implementation-defined [bugprone-narrowing-conversions]

  result.flips[result.permutations[1]] = T(1, result.permutations[1]) < 0.0;
                                              ^

flip[perm[2]] = T(2, perm[2]) < 0.0;
result.flips[result.permutations[0]] = T(0, result.permutations[0]) < 0.0;
result.flips[result.permutations[1]] = T(1, result.permutations[1]) < 0.0;
result.flips[result.permutations[2]] = T(2, result.permutations[2]) < 0.0;

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

warning: narrowing conversion from 'value_type' (aka 'unsigned long') to signed type 'Index' (aka 'long') is implementation-defined [bugprone-narrowing-conversions]

  result.flips[result.permutations[2]] = T(2, result.permutations[2]) < 0.0;
                                              ^

return (permutations[0] != 0 || permutations[1] != 1 || permutations[2] != 2 || //
flips[0] || flips[1] || flips[2]);
}
permutations_type permutations;

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

warning: member variable 'permutations' has public visibility [misc-non-private-member-variables-in-classes]

  permutations_type permutations;
                    ^

flips[0] || flips[1] || flips[2]);
}
permutations_type permutations;
flips_type flips;

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

warning: member variable 'flips' has public visibility [misc-non-private-member-variables-in-classes]

  flips_type flips;
             ^

stride(i) = -stride(i);
}

// copy back transform:
transform() = std::move(M);

// switch axes to match:
Axis a[] = {axes_[realign_perm_[0]], axes_[realign_perm_[1]], axes_[realign_perm_[2]]};
Axis a[] = {axes_[realignment_.permutation(0)], axes_[realignment_.permutation(1)], axes_[realignment_.permutation(2)]};

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

warning: variable 'a' of type 'Axis[3]' can be declared 'const' [misc-const-correctness]

Suggested change
Axis a[] = {axes_[realignment_.permutation(0)], axes_[realignment_.permutation(1)], axes_[realignment_.permutation(2)]};
Axis const a[] = {axes_[realignment_.permutation(0)], axes_[realignment_.permutation(1)], axes_[realignment_.permutation(2)]};

}

Header
concatenate(const std::vector<Header> &headers, const size_t axis_to_concat, const bool permit_datatype_mismatch) {
Header concatenate(const std::vector<Header> &headers,

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

warning: function 'concatenate' has cognitive complexity of 61 (threshold 25) [readability-function-cognitive-complexity]

Header concatenate(const std::vector<Header> &headers,
       ^
Additional context

core/header.cpp:648: nesting level increased to 1

  auto datatype_test = [&](const bool condition) {
                       ^

core/header.cpp:649: +2, including nesting penalty of 1, nesting level increased to 2

    if (condition && !permit_datatype_mismatch) {
    ^

core/header.cpp:649: +1

    if (condition && !permit_datatype_mismatch) {
                  ^

core/header.cpp:656: nesting level increased to 1

  auto concat_scheme = [](Eigen::MatrixXd &existing, const Eigen::MatrixXd &extra) {
                       ^

core/header.cpp:657: +2, including nesting penalty of 1, nesting level increased to 2

    if (!existing.rows())
    ^

core/header.cpp:659: +2, including nesting penalty of 1, nesting level increased to 2

    if (!extra.rows() || (extra.cols() != existing.cols())) {
    ^

core/header.cpp:659: +1

    if (!extra.rows() || (extra.cols() != existing.cols())) {
                      ^

core/header.cpp:667: +1, including nesting penalty of 0, nesting level increased to 1

  if (headers.empty())
  ^

core/header.cpp:671: +1, including nesting penalty of 0, nesting level increased to 1

  for (const auto &H : headers) {
  ^

core/header.cpp:672: +2, including nesting penalty of 1, nesting level increased to 2

    if (axis_to_concat > H.ndim() + 1) {
    ^

core/header.cpp:677: +2, including nesting penalty of 1, nesting level increased to 2

    for (this_max_nonunity_dim = H.ndim() - 1; this_max_nonunity_dim >= 0 && H.size(this_max_nonunity_dim) <= 1;
    ^

core/header.cpp:677: +1

    for (this_max_nonunity_dim = H.ndim() - 1; this_max_nonunity_dim >= 0 && H.size(this_max_nonunity_dim) <= 1;
                                                                          ^

core/header.cpp:685: +1, including nesting penalty of 0, nesting level increased to 1

  if (axis_to_concat >= result.ndim()) {
  ^

core/header.cpp:692: +1, including nesting penalty of 0, nesting level increased to 1

  for (size_t axis = 0; axis != result.ndim(); ++axis) {
  ^

core/header.cpp:693: +2, including nesting penalty of 1, nesting level increased to 2

    if (axis != axis_to_concat && result.size(axis) <= 1) {
    ^

core/header.cpp:693: +1

    if (axis != axis_to_concat && result.size(axis) <= 1) {
                               ^

core/header.cpp:694: +3, including nesting penalty of 2, nesting level increased to 3

      for (const auto &H : headers) {
      ^

core/header.cpp:695: +4, including nesting penalty of 3, nesting level increased to 4

        if (H.ndim() > axis) {
        ^

core/header.cpp:705: +1, including nesting penalty of 0, nesting level increased to 1

  if (axis_to_concat == 3) {
  ^

core/header.cpp:708: +2, including nesting penalty of 1, nesting level increased to 2

    } catch (Exception &) {
      ^

core/header.cpp:712: +2, including nesting penalty of 1, nesting level increased to 2

    } catch (Exception &) {
      ^

core/header.cpp:716: +1, including nesting penalty of 0, nesting level increased to 1

  for (size_t i = 1; i != headers.size(); ++i) {
  ^

core/header.cpp:720: +2, including nesting penalty of 1, nesting level increased to 2

    for (size_t axis = 0; axis <= global_max_nonunity_dim; ++axis) {
    ^

core/header.cpp:721: +3, including nesting penalty of 2, nesting level increased to 3

      if (axis != axis_to_concat && axis < H.ndim() && H.size(axis) != result.size(axis)) {
      ^

core/header.cpp:721: +1

      if (axis != axis_to_concat && axis < H.ndim() && H.size(axis) != result.size(axis)) {
                                                    ^

core/header.cpp:729: +2, including nesting penalty of 1, nesting level increased to 2

    result.size(axis_to_concat) += H.ndim() <= axis_to_concat ? 1 : H.size(axis_to_concat);
                                                              ^

core/header.cpp:732: +2, including nesting penalty of 1, nesting level increased to 2

    if (axis_to_concat == 3) {
    ^

core/header.cpp:736: +3, including nesting penalty of 2, nesting level increased to 3

      } catch (Exception &) {
        ^

core/header.cpp:742: +3, including nesting penalty of 2, nesting level increased to 3

      } catch (Exception &) {
        ^

core/header.cpp:752: +2, including nesting penalty of 1, nesting level increased to 2

    if (datatype_test(!result.datatype().is_complex() && H.datatype().is_complex()))
    ^

core/header.cpp:752: +1

    if (datatype_test(!result.datatype().is_complex() && H.datatype().is_complex()))
                                                      ^

core/header.cpp:754: +2, including nesting penalty of 1, nesting level increased to 2

    if (datatype_test(result.datatype().is_integer() && !result.datatype().is_signed() && H.datatype().is_signed()))
    ^

core/header.cpp:754: +1

    if (datatype_test(result.datatype().is_integer() && !result.datatype().is_signed() && H.datatype().is_signed()))
                                                                                       ^

core/header.cpp:756: +2, including nesting penalty of 1, nesting level increased to 2

    if (datatype_test(result.datatype().is_integer() && H.datatype().is_floating_point()))
    ^

core/header.cpp:756: +1

    if (datatype_test(result.datatype().is_integer() && H.datatype().is_floating_point()))
                                                     ^

core/header.cpp:758: +2, including nesting penalty of 1, nesting level increased to 2

    if (datatype_test(result.datatype().bytes() < H.datatype().bytes()))
    ^

core/header.cpp:762: +1, including nesting penalty of 0, nesting level increased to 1

  if (axis_to_concat == 3) {
  ^


Header::Realignment::Realignment() :
applied_transform_(applied_transform_type::Identity()),
orig_keyval_() {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

warning: initializer for member 'orig_keyval_' is redundant [readability-redundant-member-init]

Suggested change
orig_keyval_() {
{

@@ -70,8 +69,7 @@ class Header {
datatype_(std::move(H.datatype_)),
offset_(H.offset_),
scale_(H.scale_),
realign_perm_{{0, 1, 2}},
realign_flip_{{false, false, false}} {}
realignment_(H.realignment_) {}

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

warning: move constructor initializes class member by calling a copy constructor [performance-move-constructor-init]

        realignment_(H.realignment_) {}
        ^
Additional context

core/header.h:202: copy constructor being called

  class Realignment {
        ^

core/header.h:202: candidate move constructor here

  class Realignment {
        ^

@@ -397,8 +412,7 @@
void realign_transform();
/*! store information about how image was
* realigned via realign_transform(). */
std::array<size_t, 3> realign_perm_;
std::array<bool, 3> realign_flip_;
Realignment realignment_;

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

warning: member variable 'realignment_' has protected visibility [cppcoreguidelines-non-private-member-variables-in-classes]

  Realignment realignment_;
              ^

Copy link

@github-actions github-actions bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

clang-tidy made some suggestions

There were too many comments to post at once. Showing the first 25 out of 32. Check the log or trigger a new build to see more.

return nifti_transform;
const transform_type ondisk_transform = header.realignment().orig_transform();
if (ondisk_transform.matrix().topLeftCorner<3, 3>().determinant() < 0.0)
return ondisk_transform;
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

warning: constness of 'ondisk_transform' prevents automatic move [performance-no-automatic-move]

    return ondisk_transform;
           ^

transform_type coord_switch;
coord_switch.setIdentity();
coord_switch(0, 0) = -1.0f;
coord_switch(0, 3) = (header.size(axes[0]) - 1) * header.spacing(axes[0]);
return nifti_transform * coord_switch;
coord_switch(0, 3) = (header.size(header.realignment().permutation(0)) - 1) * header.spacing(header.realignment().permutation(0));
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

warning: narrowing conversion from 'ssize_t' (aka 'long') to 'default_type' (aka 'double') [bugprone-narrowing-conversions]

  coord_switch(0, 3) = (header.size(header.realignment().permutation(0)) - 1) * header.spacing(header.realignment().permutation(0));
                       ^

Comment on lines +29 to +51
} else if (dir[0] == 1) {
assert(!dir[1]);
assert(!dir[2]);
return "i";
} else if (dir[1] == -1) {
assert(!dir[0]);
assert(!dir[2]);
return "j-";
} else if (dir[1] == 1) {
assert(!dir[0]);
assert(!dir[2]);
return "j";
} else if (dir[2] == -1) {
assert(!dir[0]);
assert(!dir[1]);
return "k-";
} else if (dir[2] == 1) {
assert(!dir[0]);
assert(!dir[1]);
return "k";
} else {
throw Exception("Malformed image axis vector: \"" + str(dir.transpose()) + "\"");
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

warning: do not use 'else' after 'return' [readability-else-after-return]

Suggested change
} else if (dir[0] == 1) {
assert(!dir[1]);
assert(!dir[2]);
return "i";
} else if (dir[1] == -1) {
assert(!dir[0]);
assert(!dir[2]);
return "j-";
} else if (dir[1] == 1) {
assert(!dir[0]);
assert(!dir[2]);
return "j";
} else if (dir[2] == -1) {
assert(!dir[0]);
assert(!dir[1]);
return "k-";
} else if (dir[2] == 1) {
assert(!dir[0]);
assert(!dir[1]);
return "k";
} else {
throw Exception("Malformed image axis vector: \"" + str(dir.transpose()) + "\"");
}
} if (dir[0] == 1) {
assert(!dir[1]);
assert(!dir[2]);
return "i";
} else if (dir[1] == -1) {
assert(!dir[0]);
assert(!dir[2]);
return "j-";
} else if (dir[1] == 1) {
assert(!dir[0]);
assert(!dir[2]);
return "j";
} else if (dir[2] == -1) {
assert(!dir[0]);
assert(!dir[1]);
return "k-";
} else if (dir[2] == 1) {
assert(!dir[0]);
assert(!dir[1]);
return "k";
} else {
throw Exception("Malformed image axis vector: \"" + str(dir.transpose()) + "\"");
}

Comment on lines +57 to +68
else if (id == "i")
return {1, 0, 0};
else if (id == "j-")
return {0, -1, 0};
else if (id == "j")
return {0, 1, 0};
else if (id == "k-")
return {0, 0, -1};
else if (id == "k")
return {0, 0, 1};
else
throw Exception("Malformed image axis identifier: \"" + id + "\"");
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

warning: do not use 'else' after 'return' [readability-else-after-return]

Suggested change
else if (id == "i")
return {1, 0, 0};
else if (id == "j-")
return {0, -1, 0};
else if (id == "j")
return {0, 1, 0};
else if (id == "k-")
return {0, 0, -1};
else if (id == "k")
return {0, 0, 1};
else
throw Exception("Malformed image axis identifier: \"" + id + "\"");
if (id == "i")
return {1, 0, 0};
else if (id == "j-")
return {0, -1, 0};
else if (id == "j")
return {0, 1, 0};
else if (id == "k-")
return {0, 0, -1};
else if (id == "k")
return {0, 0, 1};
else
throw Exception("Malformed image axis identifier: \"" + id + "\"");

// clang-format on

void check(const scheme_type &PE) {
if (!PE.rows())
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

warning: implicit conversion 'Index' (aka 'long') -> bool [readability-implicit-bool-conversion]

Suggested change
if (!PE.rows())
if (PE.rows() == 0)

for (ssize_t PE_row = 0; PE_row != PE.rows(); ++PE_row) {
for (ssize_t config_row = 0; config_row != config.rows(); ++config_row) {
bool dir_match = PE.template block<1, 3>(PE_row, 0).isApprox(config.block<1, 3>(config_row, 0));
bool time_match = abs(PE(PE_row, 3) - config(config_row, 3)) < 1e-3;
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

warning: variable 'time_match' of type 'bool' can be declared 'const' [misc-const-correctness]

Suggested change
bool time_match = abs(PE(PE_row, 3) - config(config_row, 3)) < 1e-3;
bool const time_match = abs(PE(PE_row, 3) - config(config_row, 3)) < 1e-3;

bool time_match = abs(PE(PE_row, 3) - config(config_row, 3)) < 1e-3;
if (dir_match && time_match) {
// FSL-style index file indexes from 1
indices[PE_row] = config_row + 1;
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

warning: narrowing conversion from 'ssize_t' (aka 'long') to signed type 'Scalar' (aka 'int') is implementation-defined [bugprone-narrowing-conversions]

        indices[PE_row] = config_row + 1;
                          ^

// No corresponding match found in config matrix; create a new entry
config.conservativeResize(config.rows() + 1, 4);
config.row(config.rows() - 1) = PE.row(PE_row);
indices[PE_row] = config.rows();
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

warning: narrowing conversion from 'Index' (aka 'long') to signed type 'Scalar' (aka 'int') is implementation-defined [bugprone-narrowing-conversions]

      indices[PE_row] = config.rows();
                        ^


void export_commandline(const Header &header) {
auto check = [&](const scheme_type &m) -> const scheme_type & {
if (!m.rows())
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

warning: implicit conversion 'Index' (aka 'long') -> bool [readability-implicit-bool-conversion]

Suggested change
if (!m.rows())
if (m.rows() == 0)

scheme_type transform_for_nifti_write(const scheme_type &pe_scheme, const Header &H);

namespace {
void __save(const scheme_type &PE, const std::string &path) {
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

warning: declaration uses identifier '__save', which is a reserved identifier [bugprone-reserved-identifier]

Suggested change
void __save(const scheme_type &PE, const std::string &path) {
void _save(const scheme_type &PE, const std::string &path) {

core/metadata/phase_encoding.h:119:

-     __save(PE, path);
+     _save(PE, path);

@Lestropie Lestropie mentioned this pull request Sep 16, 2024
3 tasks
Lestropie added a commit that referenced this pull request Sep 19, 2024
This commit is a manual replication of the contents of #2917, but targeting 3.0.x.
Lestropie added a commit that referenced this pull request Sep 22, 2024
In order for a bvecs file to be properly read in, the information about how the image was transformed upon image load needs to be available. However if an attempt is made to perform such a read based on a Header that it has itself been constructed from an arbitrary ImageType, this information is lost. Therefore, reading from a bvecs file needs to be done using the Header class in which the corresponding NIfTI was originally opened.
In the process, some functionalities of the tracking of that transformation on image load have been made more explicit.
Also fixes some syntax errors relating to the manual back-porting of #2917 in #3011.
Lestropie added a commit that referenced this pull request Sep 22, 2024
In order for a bvecs file to be properly read in, the information about how the image was transformed upon image load needs to be available. However if an attempt is made to perform such a read based on a Header that it has itself been constructed from an arbitrary ImageType, this information is lost. Therefore, reading from a bvecs file needs to be done using the Header class in which the corresponding NIfTI was originally opened.
In the process, some functionalities of the tracking of that transformation on image load have been made more explicit.
Also fixes some syntax errors relating to the manual back-porting of #2917 in #3011.
Lestropie added a commit that referenced this pull request Sep 22, 2024
In order for a bvecs file to be properly read in, the information about how the image was transformed upon image load needs to be available. However if an attempt is made to perform such a read based on a Header that it has itself been constructed from an arbitrary ImageType, this information is lost. Therefore, reading from a bvecs file needs to be done using the Header class in which the corresponding NIfTI was originally opened.
In the process, some functionalities of the tracking of that transformation on image load have been made more explicit.
Also fixes some syntax errors relating to the manual back-porting of #2917 in #3011.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
Status: No status
Development

Successfully merging this pull request may close these issues.

1 participant