Skip to content

Commit 25ee357

Browse files
committed
BUG: Fix case where DICOM series order has negative spacing
If the spacing between slices is negative, then direction cosine matrix was not correct with reguards to the sign. This can be easily created with the "ReverseOrder" flag with the series reader and DICOM data. With the correction, a DICOM series mantains its correct physical location when this flag is enabled.
1 parent 0291f7c commit 25ee357

File tree

3 files changed

+385
-3
lines changed

3 files changed

+385
-3
lines changed

Modules/IO/GDCM/test/CMakeLists.txt

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -448,3 +448,28 @@ addcompliancetest(raw-YBR_FULL_422)
448448
addcompliancetest(RLE-RGB)
449449
addcompliancetest(HTJ2K-YBR_ICT Lily_full.mha)
450450
addcompliancetest(HTJ2K-YBR_RCT Lily_full.mha)
451+
452+
set(ITKGDCMImageIOGTests itkGDCMImageIOGTest.cxx)
453+
creategoogletestdriver(ITKGDCMImageIO "${ITKIOGDCM-Test_LIBRARIES}" "${ITKGDCMImageIOGTests}")
454+
455+
ExternalData_Expand_Arguments(
456+
ITKData
457+
_DICOM_SERIES_INPUT
458+
"DATA{${ITK_DATA_ROOT}/Input/DicomSeries/,REGEX:Image[0-9]+.dcm}"
459+
)
460+
target_compile_definitions(
461+
ITKGDCMImageIOGTestDriver
462+
PRIVATE
463+
"DICOM_SERIES_INPUT=${_DICOM_SERIES_INPUT}"
464+
PRIVATE
465+
"ITK_TEST_OUTPUT_DIR=${ITK_TEST_OUTPUT_DIR}"
466+
)
467+
468+
set_property(
469+
TARGET
470+
ITKGDCMImageIOGTestDriver
471+
APPEND
472+
PROPERTY
473+
DEPENDS
474+
ITKData
475+
)
Lines changed: 333 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,333 @@
1+
/*=========================================================================
2+
*
3+
* Copyright NumFOCUS
4+
*
5+
* Licensed under the Apache License, Version 2.0 (the "License");
6+
* you may not use this file except in compliance with the License.
7+
* You may obtain a copy of the License at
8+
*
9+
* https://www.apache.org/licenses/LICENSE-2.0.txt
10+
*
11+
* Unless required by applicable law or agreed to in writing, software
12+
* distributed under the License is distributed on an "AS IS" BASIS,
13+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
* See the License for the specific language governing permissions and
15+
* limitations under the License.
16+
*
17+
*=========================================================================*/
18+
19+
#include "itkImage.h"
20+
#include "itkImageFileWriter.h"
21+
#include "itkGDCMImageIO.h"
22+
#include "itkGDCMSeriesFileNames.h"
23+
#include "itkMetaDataObject.h"
24+
#include "itkGTest.h"
25+
#include "itksys/SystemTools.hxx"
26+
#include "itkImageSeriesReader.h"
27+
#include <string>
28+
#include <vector>
29+
30+
31+
#define _STRING(s) #s
32+
#define TOSTRING(s) std::string(_STRING(s))
33+
34+
namespace
35+
{
36+
37+
struct ITKGDCMImageIO : public ::testing::Test
38+
{
39+
void
40+
SetUp() override
41+
{
42+
itksys::SystemTools::MakeDirectory(m_TempDir);
43+
}
44+
45+
void
46+
TearDown() override
47+
{
48+
itksys::SystemTools::RemoveADirectory(m_TempDir);
49+
}
50+
51+
const std::string m_TempDir{ TOSTRING(ITK_TEST_OUTPUT_DIR) + "/ITKGDCMImageIO" };
52+
const std::string m_DicomSeriesInput{ TOSTRING(DICOM_SERIES_INPUT) };
53+
};
54+
55+
class ITKGDCMSeriesTestData : public ITKGDCMImageIO
56+
{
57+
public:
58+
using PixelType = uint16_t;
59+
static constexpr unsigned int Dimension = 2;
60+
using ImageType = itk::Image<PixelType, Dimension>;
61+
using WriterType = itk::ImageFileWriter<ImageType>;
62+
63+
void
64+
SetUp() override
65+
{
66+
itksys::SystemTools::MakeDirectory(m_TempDir);
67+
CreateTestDicomSeries();
68+
}
69+
70+
void
71+
TearDown() override
72+
{
73+
itksys::SystemTools::RemoveADirectory(m_TempDir);
74+
}
75+
76+
protected:
77+
const std::string m_TempDir{ TOSTRING(ITK_TEST_OUTPUT_DIR) + "/ITKGDCMSeriesTestData" };
78+
std::vector<std::string> m_DicomFiles;
79+
80+
81+
private:
82+
void
83+
CreateTestDicomSeries()
84+
{
85+
// DICOM meta-data values from the report
86+
const std::vector<std::string> positions = {
87+
"-216.500\\-216.500\\70.000", // slice 1 (top)
88+
"-216.500\\-216.500\\-187.500", // slice 2 (middle)
89+
"-216.500\\-216.500\\-445.000" // slice 3 (bottom)
90+
};
91+
92+
const std::string orientation = "1.000000\\0.000000\\0.000000\\0.000000\\1.000000\\0.000000";
93+
94+
// Create a 2x2 image for each slice (equivalent to 3D [2,2,3] sliced)
95+
ImageType::SizeType size;
96+
size[0] = 2;
97+
size[1] = 2;
98+
99+
ImageType::IndexType start;
100+
start.Fill(0);
101+
102+
ImageType::RegionType region(start, size);
103+
104+
auto gdcmIO = itk::GDCMImageIO::New();
105+
gdcmIO->KeepOriginalUIDOn();
106+
auto writer = WriterType::New();
107+
writer->SetImageIO(gdcmIO);
108+
109+
// Write each slice as a DICOM file with appropriate tags
110+
for (size_t i = 0; i < positions.size(); ++i)
111+
{
112+
auto image = ImageType::New();
113+
image->SetRegions(region);
114+
image->Allocate();
115+
image->FillBuffer(100); // Just to have nonzero pixel values
116+
117+
// Get the metadata dictionary
118+
auto & dict = image->GetMetaDataDictionary();
119+
120+
// Set required DICOM tags
121+
itk::EncapsulateMetaData<std::string>(dict, "0020|0032", positions[i]); // ImagePositionPatient
122+
itk::EncapsulateMetaData<std::string>(dict, "0020|0037", orientation); // ImageOrientationPatient
123+
itk::EncapsulateMetaData<std::string>(dict, "0008|0060", "CT"); // Modality
124+
itk::EncapsulateMetaData<std::string>(dict, "0020|0013", std::to_string(i + 1)); // InstanceNumber
125+
itk::EncapsulateMetaData<std::string>(dict, "0010|0010", "Test^Patient"); // PatientName
126+
itk::EncapsulateMetaData<std::string>(
127+
dict, "0020|000e", "1.2.3.4.5.6.7.8"); // SeriesInstanceUID (same for all slices)
128+
129+
// Additional required DICOM tags for proper series
130+
itk::EncapsulateMetaData<std::string>(
131+
dict, "0008|0016", "1.2.840.10008.5.1.4.1.1.2"); // SOPClassUID (CT Image Storage)
132+
itk::EncapsulateMetaData<std::string>(
133+
dict, "0008|0018", "1.2.3.4.5.6.7.8.9." + std::to_string(i + 1)); // SOPInstanceUID
134+
itk::EncapsulateMetaData<std::string>(dict, "0020|000d", "1.2.3.4.5.6.7.8"); // StudyInstanceUID
135+
itk::EncapsulateMetaData<std::string>(dict, "0010|0020", "12345"); // PatientID
136+
itk::EncapsulateMetaData<std::string>(dict, "0008|0020", "20240101"); // StudyDate
137+
itk::EncapsulateMetaData<std::string>(dict, "0008|0030", "120000"); // StudyTime
138+
139+
const std::string filename = m_TempDir + "/slice_" + std::to_string(i + 1) + ".dcm";
140+
writer->SetFileName(filename);
141+
writer->SetInput(image);
142+
writer->Update();
143+
144+
m_DicomFiles.push_back(filename);
145+
}
146+
}
147+
};
148+
149+
} // namespace
150+
151+
TEST_F(ITKGDCMSeriesTestData, ReadSlicesReverseOrder)
152+
{
153+
// This test image series has non-unit meta-data:
154+
// spacing: [0.859375, 0.85939, 1.60016]
155+
// origin:[-112, -21.688, 126.894]
156+
// direction:
157+
// [1 0 0,
158+
// 0 0.466651 0.884442,
159+
// 0 -0.884442 0.466651]
160+
constexpr unsigned int VolumeDimension = 3;
161+
using VolumeImageType = itk::Image<PixelType, VolumeDimension>;
162+
163+
using NamesGeneratorType = itk::GDCMSeriesFileNames;
164+
auto namesGenerator = NamesGeneratorType::New();
165+
namesGenerator->SetDirectory(m_DicomSeriesInput);
166+
namesGenerator->SetUseSeriesDetails(true);
167+
std::vector<std::string> fileNames = namesGenerator->GetInputFileNames();
168+
169+
using SeriesReaderType = itk::ImageSeriesReader<VolumeImageType>;
170+
auto seriesReader = SeriesReaderType::New();
171+
seriesReader->SetFileNames(fileNames);
172+
auto gdcmIO = itk::GDCMImageIO::New();
173+
seriesReader->SetImageIO(gdcmIO);
174+
ASSERT_NO_THROW(seriesReader->UpdateLargestPossibleRegion());
175+
176+
VolumeImageType::Pointer outputImage = seriesReader->GetOutput();
177+
outputImage->DisconnectPipeline();
178+
179+
seriesReader->ReverseOrderOn();
180+
ASSERT_NO_THROW(seriesReader->UpdateLargestPossibleRegion());
181+
VolumeImageType::Pointer reversedOutputImage = seriesReader->GetOutput();
182+
reversedOutputImage->DisconnectPipeline();
183+
184+
std::cout << "baseline direction: " << outputImage->GetDirection() << std::endl;
185+
std::cout << "reversed direction: " << reversedOutputImage->GetDirection() << std::endl;
186+
EXPECT_EQ(outputImage->GetLargestPossibleRegion().GetSize(),
187+
reversedOutputImage->GetLargestPossibleRegion().GetSize());
188+
ITK_EXPECT_VECTOR_NEAR(outputImage->GetSpacing(), reversedOutputImage->GetSpacing(), 1e-6);
189+
EXPECT_NE(outputImage->GetOrigin(), reversedOutputImage->GetOrigin());
190+
191+
// calculate the index at the middle of the image
192+
VolumeImageType::IndexType middleIndex;
193+
for (unsigned int d = 0; d < VolumeDimension; ++d)
194+
{
195+
middleIndex[d] =
196+
outputImage->GetLargestPossibleRegion().GetIndex()[d] + outputImage->GetLargestPossibleRegion().GetSize()[d] / 2;
197+
}
198+
199+
const std::vector<VolumeImageType::IndexType> testIndices = {
200+
{ { 0, 0, 0 } }, { { 1, 1, 1 } }, { { 2, 2, 2 } }, middleIndex
201+
};
202+
203+
// test that the reversed image has the same pixel values at the same physical location
204+
for (const auto & idx : testIndices)
205+
{
206+
VolumeImageType::PointType point;
207+
outputImage->TransformIndexToPhysicalPoint(idx, point);
208+
auto reverseIdx = reversedOutputImage->TransformPhysicalPointToIndex(point);
209+
210+
std::cout << "Testing idx: " << idx << " reverseIdx: " << reverseIdx << std::endl;
211+
ASSERT_TRUE(reversedOutputImage->GetLargestPossibleRegion().IsInside(reverseIdx));
212+
EXPECT_EQ(outputImage->GetPixel(idx), reversedOutputImage->GetPixel(reverseIdx));
213+
}
214+
}
215+
216+
TEST_F(ITKGDCMSeriesTestData, CreateAndReadTestSeries)
217+
{
218+
// Verify that the DICOM files were created
219+
ASSERT_EQ(m_DicomFiles.size(), 3);
220+
221+
for (const auto & filename : m_DicomFiles)
222+
{
223+
ASSERT_TRUE(itksys::SystemTools::FileExists(filename));
224+
}
225+
226+
// Read the series using GDCMSeriesFileNames
227+
using NamesGeneratorType = itk::GDCMSeriesFileNames;
228+
auto namesGenerator = NamesGeneratorType::New();
229+
namesGenerator->SetDirectory(m_TempDir);
230+
namesGenerator->SetUseSeriesDetails(true);
231+
232+
std::vector<std::string> fileNames = namesGenerator->GetInputFileNames();
233+
ASSERT_EQ(fileNames.size(), 3);
234+
235+
// Read the series
236+
using ImageType3D = itk::Image<uint16_t, 3>;
237+
using SeriesReaderType = itk::ImageSeriesReader<ImageType3D>;
238+
auto seriesReader = SeriesReaderType::New();
239+
seriesReader->SetFileNames(fileNames);
240+
241+
auto gdcmIO = itk::GDCMImageIO::New();
242+
seriesReader->SetImageIO(gdcmIO);
243+
244+
ASSERT_NO_THROW(seriesReader->UpdateLargestPossibleRegion());
245+
246+
ImageType3D::Pointer image = seriesReader->GetOutput();
247+
248+
// Verify image properties
249+
ImageType3D::SizeType expectedSize = { { 2, 2, 3 } };
250+
EXPECT_EQ(image->GetLargestPossibleRegion().GetSize(), expectedSize);
251+
252+
// Verify pixel values (should be 100)
253+
EXPECT_EQ(image->GetPixel({ 0, 0, 0 }), 100);
254+
EXPECT_EQ(image->GetPixel({ 1, 1, 1 }), 100);
255+
}
256+
257+
TEST_F(ITKGDCMSeriesTestData, ReadSeriesTopToBottom)
258+
{
259+
// Read in top-to-bottom order (files ordered by ImagePositionPatient Z coordinate)
260+
using ImageType3D = itk::Image<uint16_t, 3>;
261+
using SeriesReaderType = itk::ImageSeriesReader<ImageType3D>;
262+
263+
// Get file list in top-to-bottom order
264+
const std::vector<std::string> & filesTopToBottom = m_DicomFiles;
265+
266+
auto seriesReader = SeriesReaderType::New();
267+
auto gdcmIO = itk::GDCMImageIO::New();
268+
seriesReader->SetImageIO(gdcmIO);
269+
seriesReader->SetFileNames(filesTopToBottom);
270+
seriesReader->ForceOrthogonalDirectionOn(); // explicitly set default
271+
272+
ASSERT_NO_THROW(seriesReader->UpdateLargestPossibleRegion());
273+
ImageType3D::Pointer image1 = seriesReader->GetOutput();
274+
275+
std::cout << "Top-to-bottom order:" << std::endl;
276+
std::cout << " Origin: " << image1->GetOrigin() << std::endl;
277+
std::cout << " Direction: " << image1->GetDirection() << std::endl;
278+
std::cout << " Spacing: " << image1->GetSpacing() << std::endl;
279+
280+
// Verify image properties
281+
ImageType3D::SizeType expectedSize = { { 2, 2, 3 } };
282+
EXPECT_EQ(image1->GetLargestPossibleRegion().GetSize(), expectedSize);
283+
284+
// The origin should be at the position of the first slice (top slice)
285+
ImageType3D::PointType expectedOrigin{ { -216.500, -216.500, 70.000 } }; // Z position of slice 1 (top)
286+
287+
ITK_EXPECT_VECTOR_NEAR(image1->GetOrigin(), expectedOrigin, 1e-3);
288+
289+
// Z spacing should be positive
290+
EXPECT_GT(image1->GetSpacing()[2], 0.0);
291+
// but the direction should have a negative Z component
292+
EXPECT_LT(image1->GetDirection()[2][2], 0.0);
293+
}
294+
295+
TEST_F(ITKGDCMSeriesTestData, ReadSeriesBottomToTop)
296+
{
297+
// Read in bottom-to-top order (files ordered by ImagePositionPatient Z coordinate)
298+
using ImageType3D = itk::Image<uint16_t, 3>;
299+
using SeriesReaderType = itk::ImageSeriesReader<ImageType3D>;
300+
301+
// Get file list in bottom-to-top order
302+
std::vector<std::string> filesBottomToTop(m_DicomFiles.rbegin(), m_DicomFiles.rend());
303+
304+
auto seriesReader = SeriesReaderType::New();
305+
auto gdcmIO = itk::GDCMImageIO::New();
306+
seriesReader->SetImageIO(gdcmIO);
307+
seriesReader->SetFileNames(filesBottomToTop);
308+
seriesReader->ForceOrthogonalDirectionOn(); // explicitly set default
309+
310+
ASSERT_NO_THROW(seriesReader->UpdateLargestPossibleRegion());
311+
ImageType3D::Pointer image2 = seriesReader->GetOutput();
312+
313+
std::cout << "Bottom-to-top order:" << std::endl;
314+
std::cout << " Origin: " << image2->GetOrigin() << std::endl;
315+
std::cout << " Direction: " << image2->GetDirection() << std::endl;
316+
std::cout << " Spacing: " << image2->GetSpacing() << std::endl;
317+
318+
// Verify image properties
319+
ImageType3D::SizeType expectedSize = { { 2, 2, 3 } };
320+
EXPECT_EQ(image2->GetLargestPossibleRegion().GetSize(), expectedSize);
321+
322+
// The origin should be at the position of the first slice (bottom slice)
323+
ImageType3D::PointType expectedOrigin{
324+
{ -216.500, -216.500, -445.000 }
325+
}; // X,Y from slice 1, Z position of slice 3 (bottom)
326+
327+
ITK_EXPECT_VECTOR_NEAR(image2->GetOrigin(), expectedOrigin, 1e-3);
328+
329+
// Z spacing should be positive (going from bottom to top)
330+
EXPECT_GT(image2->GetSpacing()[2], 0.0);
331+
// and the direction should have a positive Z component
332+
EXPECT_GT(image2->GetDirection()[2][2], 0.0);
333+
}

0 commit comments

Comments
 (0)