Skip to content

Commit e90b1bb

Browse files
hjmjohnsonclaude
andcommitted
ENH: Add precision round-trip tests for float metadata serialization
Adds test coverage to four IO modules verifying that float/double values survive disk round-trips without the 6-digit truncation that default stream precision introduces (ITK issue #3249): * itkNiftiImageIOTest3.cxx: TestNiftiFloatMetadataPrecision() writes an image with 0.12345679-mm spacing, reads back, and verifies the MetaDataDictionary["pixdim[1]"] string parses to the exact float32. * itkMetaImageIOMetaDataTest.cxx: encapsulates hpDouble=1.2345678901234568 and hpFloat=1.2345679f, writes/reads MetaImage, checks exact equality via stod/stof (bypassing the loose 1e-6 tolerance in Equal<double>). * itkNrrdMetaDataTest.cxx: same pattern for native double and float NRRD metadata keys. * itkVoxBoCUBImageIOTest.cxx: creates an image with 0.12345678901234 spacing, writes/reads CUB, asserts GetSpacing()[0] == original double. These tests FAIL without the ConvertNumberToString fixes in PR #6003 and PASS after them (Closes #3249). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 0e977f2 commit e90b1bb

File tree

4 files changed

+278
-4
lines changed

4 files changed

+278
-4
lines changed

Modules/IO/Meta/test/itkMetaImageIOMetaDataTest.cxx

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
#include "itkRandomImageSource.h"
2222
#include "itkMetaDataObject.h"
2323
#include "itkMetaImageIO.h"
24+
#include "itkNumberToString.h"
2425
#include "itkTestingMacros.h"
2526

2627

@@ -341,5 +342,60 @@ itkMetaImageIOMetaDataTest(int argc, char * argv[])
341342
return 1; // error
342343
}
343344

345+
// Precision test: verify that double and float metadata round-trips without
346+
// the 6-digit truncation introduced by default stream precision.
347+
//
348+
// With default stream precision the MetaImageIO write path serialised a
349+
// double like 1.2345678901234568 as "1.23457", which on read-back parses
350+
// to a different double bit-pattern. itk::ConvertNumberToString() produces
351+
// the shortest decimal string that round-trips exactly.
352+
//
353+
// These checks FAIL against the unfixed MetaImageIO and PASS with the fix.
354+
{
355+
// Values chosen to require more than 6 significant decimal digits.
356+
constexpr double hpDouble = 1.2345678901234568;
357+
constexpr float hpFloat = 1.2345679f;
358+
359+
ImageType::Pointer precImage(source->GetOutput());
360+
itk::MetaDataDictionary & precDict = precImage->GetMetaDataDictionary();
361+
itk::EncapsulateMetaData<double>(precDict, std::string("high_precision_double"), hpDouble);
362+
itk::EncapsulateMetaData<float>(precDict, std::string("high_precision_float"), hpFloat);
363+
364+
WriteImage<ImageType>(precImage, argv[1]);
365+
366+
const ImageType::Pointer precImage2 = ReadImage<ImageType>(argv[1]);
367+
itk::MetaDataDictionary & precDict2 = precImage2->GetMetaDataDictionary();
368+
369+
// Extract raw string and parse back with exact equality to detect
370+
// any bit-level precision loss from the string serialization.
371+
std::string doubleStr;
372+
if (!itk::ExposeMetaData<std::string>(precDict2, std::string("high_precision_double"), doubleStr))
373+
{
374+
std::cerr << "Key high_precision_double not found after round-trip\n";
375+
return 1;
376+
}
377+
const double parsedDouble = std::stod(doubleStr);
378+
if (parsedDouble != hpDouble)
379+
{
380+
std::cerr << "Double precision loss: stored " << itk::ConvertNumberToString(hpDouble) << " but string '"
381+
<< doubleStr << "' parses to " << itk::ConvertNumberToString(parsedDouble) << '\n';
382+
return 1;
383+
}
384+
385+
std::string floatStr;
386+
if (!itk::ExposeMetaData<std::string>(precDict2, std::string("high_precision_float"), floatStr))
387+
{
388+
std::cerr << "Key high_precision_float not found after round-trip\n";
389+
return 1;
390+
}
391+
const float parsedFloat = std::stof(floatStr);
392+
if (parsedFloat != hpFloat)
393+
{
394+
std::cerr << "Float precision loss: stored " << itk::ConvertNumberToString(hpFloat) << " but string '" << floatStr
395+
<< "' parses to " << itk::ConvertNumberToString(parsedFloat) << '\n';
396+
return 1;
397+
}
398+
}
399+
344400
return 0;
345401
}

Modules/IO/NIFTI/test/itkNiftiImageIOTest3.cxx

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
*=========================================================================*/
1818

1919
#include "itkNiftiImageIOTest.h"
20+
#include "itkNumberToString.h"
2021
#include <type_traits> // for enable_if
2122
#include <limits>
2223

@@ -284,6 +285,94 @@ TestImageOfVectors(const std::string & fname, const std::string & intentCode = "
284285
return same ? 0 : EXIT_FAILURE;
285286
}
286287

288+
/** Verify that NIfTI pixdim[] float header fields round-trip without
289+
* precision loss through the string metadata dictionary.
290+
*
291+
* NIfTI stores spatial information (pixdim[]) as binary float32. When ITK
292+
* reads a NIfTI file it converts these values to strings in the
293+
* MetaDataDictionary. With default stream precision (6 sig-digits) a value
294+
* like 0.12345679f would be serialised as "0.123457", which parses back to a
295+
* different float. With itk::ConvertNumberToString() the round-trip is exact.
296+
*
297+
* This test FAILS without the ConvertNumberToString fix in itkNiftiImageIO.cxx.
298+
*/
299+
int
300+
TestNiftiFloatMetadataPrecision(const std::string & fname)
301+
{
302+
using ImageType = itk::Image<float, 3>;
303+
auto image = ImageType::New();
304+
ImageType::SizeType size;
305+
size.Fill(2);
306+
image->SetRegions(size);
307+
image->Allocate(true);
308+
309+
// Choose a spacing value that needs more than 6 significant decimal digits.
310+
// NIfTI stores spacing as float32 in pixdim[].
311+
// The write path casts GetSpacing(0) to float; the read path converts that
312+
// float32 back to a string stored in MetaDataDictionary["pixdim[1]"].
313+
// stof("0.123457") != 0.12345679f, so the default-precision string loses
314+
// information. ConvertNumberToString produces the shortest exact string.
315+
constexpr double spacingValue = 0.123456789;
316+
// The float32 that will actually be stored in the NIfTI binary header:
317+
const float spacingAsFloat = static_cast<float>(spacingValue);
318+
319+
ImageType::SpacingType spacing;
320+
spacing[0] = spacingValue;
321+
spacing[1] = 1.0;
322+
spacing[2] = 1.0;
323+
image->SetSpacing(spacing);
324+
325+
try
326+
{
327+
itk::IOTestHelper::WriteImage<ImageType, itk::NiftiImageIO>(image, fname);
328+
}
329+
catch (const itk::ExceptionObject & ex)
330+
{
331+
std::cerr << "TestNiftiFloatMetadataPrecision: write failed: " << ex << '\n';
332+
return EXIT_FAILURE;
333+
}
334+
335+
ImageType::Pointer readback;
336+
try
337+
{
338+
readback = itk::IOTestHelper::ReadImage<ImageType>(fname);
339+
}
340+
catch (const itk::ExceptionObject & ex)
341+
{
342+
std::cerr << "TestNiftiFloatMetadataPrecision: read failed: " << ex << '\n';
343+
itk::IOTestHelper::Remove(fname.c_str());
344+
return EXIT_FAILURE;
345+
}
346+
347+
// The MetaDataDictionary key for pixdim[1] (spacing along dimension 0).
348+
const itk::MetaDataDictionary & rdict = readback->GetMetaDataDictionary();
349+
bool pass = true;
350+
351+
std::string pixdim1Str;
352+
if (itk::ExposeMetaData<std::string>(rdict, "pixdim[1]", pixdim1Str))
353+
{
354+
const float parsedSpacing = std::stof(pixdim1Str);
355+
if (parsedSpacing != spacingAsFloat)
356+
{
357+
std::cerr << "pixdim[1] precision loss: stored float32 " << itk::ConvertNumberToString(spacingAsFloat)
358+
<< " but MetaData string '" << pixdim1Str << "' parses to " << itk::ConvertNumberToString(parsedSpacing)
359+
<< '\n';
360+
pass = false;
361+
}
362+
}
363+
else
364+
{
365+
std::cerr << "pixdim[1] key not found after round-trip\n";
366+
pass = false;
367+
}
368+
369+
if (pass)
370+
{
371+
itk::IOTestHelper::Remove(fname.c_str());
372+
}
373+
return pass ? EXIT_SUCCESS : EXIT_FAILURE;
374+
}
375+
287376
/** Test writing and reading a Vector Image
288377
*/
289378
int
@@ -323,5 +412,7 @@ itkNiftiImageIOTest3(int argc, char * argv[])
323412
success |= TestImageOfVectors<double, 3, 1>(std::string("testDispacementImage_double.nii.gz"), std::string("1006"));
324413
success |= TestImageOfVectors<float, 3, 1>(std::string("testDisplacementImage_float.nii.gz"), std::string("1006"));
325414

415+
success |= TestNiftiFloatMetadataPrecision(std::string("testFloatMetadataPrecision.nii.gz"));
416+
326417
return success;
327418
}

Modules/IO/NRRD/test/itkNrrdMetaDataTest.cxx

Lines changed: 81 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
#include "itkImageFileReader.h"
1919
#include "itkImageFileWriter.h"
2020
#include "itkMetaDataObject.h"
21+
#include "itkNumberToString.h"
2122
#include "itksys/SystemTools.hxx"
2223
#include "itkNrrdImageIO.h"
2324

@@ -90,10 +91,86 @@ itkNrrdMetaDataTest(int argc, char * argv[])
9091
std::string NrrdTest;
9192
// if it exists and the string matches what we put in on the image
9293
// to write, AOK.
93-
if (itk::ExposeMetaData<std::string>(dict, metaDataObjectName, NrrdTest) != false && NrrdTest == metaDataObjectValue)
94+
if (itk::ExposeMetaData<std::string>(dict, metaDataObjectName, NrrdTest) == false || NrrdTest != metaDataObjectValue)
9495
{
95-
return EXIT_SUCCESS;
96+
return EXIT_FAILURE; // oops!
9697
}
97-
// oops!
98-
return EXIT_FAILURE;
98+
99+
// Precision test: verify that native double and float metadata round-trips
100+
// without the 6-digit truncation from default stream precision.
101+
//
102+
// The NrrdImageIO write path serialises native double/float metadata via
103+
// _dump_metadata_to_stream<T>. Without the ConvertNumberToString fix the
104+
// output is truncated to 6 significant digits. The read-back string then
105+
// parses to a different bit-pattern.
106+
//
107+
// These checks FAIL against the unfixed NrrdImageIO and PASS with the fix.
108+
{
109+
constexpr double hpDouble = 1.2345678901234568;
110+
constexpr float hpFloat = 1.2345679f;
111+
const char * const hpDoubleKey = "high_precision_double";
112+
const char * const hpFloatKey = "high_precision_float";
113+
114+
// Write a fresh image with high-precision double and float metadata.
115+
itk::MetaDataDictionary & dict2 = image1->GetMetaDataDictionary();
116+
itk::EncapsulateMetaData<double>(dict2, hpDoubleKey, hpDouble);
117+
itk::EncapsulateMetaData<float>(dict2, hpFloatKey, hpFloat);
118+
119+
std::string precFname = argv[1];
120+
precFname += "/metadatatest_precision.nrrd";
121+
122+
auto precWriter = ImageWriterType::New();
123+
precWriter->SetImageIO(itk::NrrdImageIO::New());
124+
precWriter->SetFileName(precFname.c_str());
125+
precWriter->SetInput(image1);
126+
127+
auto precReader = ImageReaderType::New();
128+
precReader->SetFileName(precFname.c_str());
129+
precReader->SetImageIO(itk::NrrdImageIO::New());
130+
131+
try
132+
{
133+
precWriter->Update();
134+
precReader->Update();
135+
}
136+
catch (const itk::ExceptionObject & ex)
137+
{
138+
std::cerr << "Precision test write/read failed: " << ex << '\n';
139+
return EXIT_FAILURE;
140+
}
141+
142+
const itk::MetaDataDictionary & rdict = precReader->GetOutput()->GetMetaDataDictionary();
143+
144+
std::string doubleStr;
145+
if (!itk::ExposeMetaData<std::string>(rdict, hpDoubleKey, doubleStr))
146+
{
147+
std::cerr << "Key " << hpDoubleKey << " not found after round-trip\n";
148+
return EXIT_FAILURE;
149+
}
150+
const double parsedDouble = std::stod(doubleStr);
151+
if (parsedDouble != hpDouble)
152+
{
153+
std::cerr << "Double precision loss: stored " << itk::ConvertNumberToString(hpDouble) << " but NRRD string '"
154+
<< doubleStr << "' parses to " << itk::ConvertNumberToString(parsedDouble) << '\n';
155+
return EXIT_FAILURE;
156+
}
157+
158+
std::string floatStr;
159+
if (!itk::ExposeMetaData<std::string>(rdict, hpFloatKey, floatStr))
160+
{
161+
std::cerr << "Key " << hpFloatKey << " not found after round-trip\n";
162+
return EXIT_FAILURE;
163+
}
164+
const float parsedFloat = std::stof(floatStr);
165+
if (parsedFloat != hpFloat)
166+
{
167+
std::cerr << "Float precision loss: stored " << itk::ConvertNumberToString(hpFloat) << " but NRRD string '"
168+
<< floatStr << "' parses to " << itk::ConvertNumberToString(parsedFloat) << '\n';
169+
return EXIT_FAILURE;
170+
}
171+
172+
itksys::SystemTools::RemoveFile(precFname);
173+
}
174+
175+
return EXIT_SUCCESS;
99176
}

Modules/Nonunit/Review/test/itkVoxBoCUBImageIOTest.cxx

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,10 @@
2020
#include "itkImageFileWriter.h"
2121

2222
#include "itkVoxBoCUBImageIOFactory.h"
23+
#include "itkVoxBoCUBImageIO.h"
24+
#include "itkNumberToString.h"
2325
#include "itkTestingMacros.h"
26+
#include "itksys/SystemTools.hxx"
2427

2528
int
2629
itkVoxBoCUBImageIOTest(int argc, char * argv[])
@@ -51,6 +54,53 @@ itkVoxBoCUBImageIOTest(int argc, char * argv[])
5154

5255
ITK_TRY_EXPECT_NO_EXCEPTION(writer->Update());
5356

57+
// Precision test: verify that voxel spacing round-trips without the
58+
// 6-digit truncation introduced by default stream precision.
59+
//
60+
// The VoxBoCUBImageIO write path serialises m_Spacing[] as text in the CUB
61+
// header. Without the ConvertNumberToString fix the string has only 6
62+
// significant digits, so reading back produces a different double value.
63+
//
64+
// This sub-test FAILS against the unfixed VoxBoCUBImageIO.
65+
{
66+
// Choose a spacing value that needs more than 6 significant decimal digits.
67+
constexpr double hpSpacing = 0.123456789012345;
68+
69+
auto precImage = ImageType::New();
70+
ImageType::SizeType precSize;
71+
precSize.Fill(2);
72+
precImage->SetRegions(precSize);
73+
precImage->Allocate(true);
74+
75+
ImageType::SpacingType spacing;
76+
spacing[0] = hpSpacing;
77+
spacing[1] = hpSpacing;
78+
spacing[2] = hpSpacing;
79+
precImage->SetSpacing(spacing);
80+
81+
const std::string precFname = std::string(argv[2]) + "_precision.cub";
82+
83+
auto precWriter = WriterType::New();
84+
precWriter->SetFileName(precFname.c_str());
85+
precWriter->SetImageIO(itk::VoxBoCUBImageIO::New());
86+
precWriter->SetInput(precImage);
87+
88+
auto precReader = ReaderType::New();
89+
precReader->SetFileName(precFname.c_str());
90+
precReader->SetImageIO(itk::VoxBoCUBImageIO::New());
91+
92+
ITK_TRY_EXPECT_NO_EXCEPTION(precWriter->Update());
93+
ITK_TRY_EXPECT_NO_EXCEPTION(precReader->Update());
94+
95+
const double readSpacing = precReader->GetOutput()->GetSpacing()[0];
96+
itksys::SystemTools::RemoveFile(precFname);
97+
if (readSpacing != hpSpacing)
98+
{
99+
std::cerr << "VoxBoCUB spacing precision loss: wrote " << itk::ConvertNumberToString(hpSpacing)
100+
<< " but read back " << itk::ConvertNumberToString(readSpacing) << '\n';
101+
return EXIT_FAILURE;
102+
}
103+
}
54104

55105
std::cout << "Test finished." << std::endl;
56106
return EXIT_SUCCESS;

0 commit comments

Comments
 (0)