# HG changeset patch # User Sebastien Jodogne <s.jodogne@gmail.com> # Date 1743850562 -7200 # Node ID 8964b391fe92ff9980d749da862d1651e86fb0b7 # Parent 6e04db96fae3b45825cd22126f79eb69579fcbed added DicomImageInformation::ComputeRenderingTransform() diff -r 6e04db96fae3 -r 8964b391fe92 OrthancFramework/Sources/DicomFormat/DicomImageInformation.cpp --- a/OrthancFramework/Sources/DicomFormat/DicomImageInformation.cpp Fri Apr 04 22:12:35 2025 +0200 +++ b/OrthancFramework/Sources/DicomFormat/DicomImageInformation.cpp Sat Apr 05 12:56:02 2025 +0200 @@ -33,6 +33,7 @@ #include "../Compatibility.h" #include "../Logging.h" #include "../OrthancException.h" +#include "../SerializationToolbox.h" #include "../Toolbox.h" #include <boost/lexical_cast.hpp> @@ -235,6 +236,54 @@ isPlanar_ = (planarConfiguration != 0 ? true : false); isSigned_ = (pixelRepresentation != 0 ? true : false); + + // New in Orthanc 1.12.7 + double d; + + if (values.ParseDouble(d, DICOM_TAG_RESCALE_SLOPE)) + { + rescaleSlope_ = d; + } + else + { + rescaleSlope_ = 1; + } + + if (values.ParseDouble(d, DICOM_TAG_RESCALE_INTERCEPT)) + { + rescaleIntercept_ = d; + } + else + { + rescaleIntercept_ = 0; + } + + if (values.ParseDouble(d, DICOM_TAG_DOSE_GRID_SCALING)) + { + rescaleSlope_ *= d; + } + + const std::string centerTag = values.GetStringValue(DICOM_TAG_WINDOW_CENTER, "", false); + const std::string widthTag = values.GetStringValue(DICOM_TAG_WINDOW_WIDTH, "", false); + if (!centerTag.empty() && + !widthTag.empty()) + { + std::vector<std::string> centers, widths; + Toolbox::TokenizeString(centers, centerTag, '\\'); + Toolbox::TokenizeString(widths, widthTag, '\\'); + if (centers.size() == widths.size()) + { + for (size_t i = 0; i < centers.size(); i++) + { + double center, width; + if (SerializationToolbox::ParseDouble(center, centers[i]) && + SerializationToolbox::ParseDouble(width, widths[i])) + { + windows_.push_back(Window(center, width)); + } + } + } + } } DicomImageInformation* DicomImageInformation::Clone() const @@ -251,6 +300,9 @@ target->bitsStored_ = bitsStored_; target->highBit_ = highBit_; target->photometric_ = photometric_; + target->rescaleSlope_ = rescaleSlope_; + target->rescaleIntercept_ = rescaleIntercept_; + target->windows_ = windows_; return target.release(); } @@ -472,4 +524,90 @@ return ValueRepresentation_OtherByte; } } + + + double DicomImageInformation::GetWindowCenter(size_t index) const + { + if (index < windows_.size()) + { + return windows_[index].GetCenter(); + } + else + { + throw OrthancException(ErrorCode_ParameterOutOfRange); + } + } + + + double DicomImageInformation::GetWindowWidth(size_t index) const + { + if (index < windows_.size()) + { + return windows_[index].GetWidth(); + } + else + { + throw OrthancException(ErrorCode_ParameterOutOfRange); + } + } + + + double DicomImageInformation::ApplyRescale(double value) const + { + return rescaleSlope_ * value + rescaleIntercept_; + } + + + void DicomImageInformation::GetDefaultWindowing(double& center, + double& width) const + { + if (windows_.empty()) + { + width = static_cast<double>(1 << GetBitsStored()); + center = width / 2.0; + } + else + { + center = windows_[0].GetCenter(); + width = windows_[0].GetWidth(); + } + } + + + void DicomImageInformation::ComputeRenderingTransform(double& offset, + double& scaling, + double windowCenter, + double windowWidth) const + { + // Check out "../../../OrthancServer/Resources/ImplementationNotes/windowing.py" + + windowWidth = std::abs(windowWidth); + + // Avoid divisions by zero + static const double MIN = 0.0001; + if (windowWidth <= MIN) + { + windowWidth = MIN; + } + + if (GetPhotometricInterpretation() == PhotometricInterpretation_Monochrome1) + { + scaling = -255.0 * GetRescaleSlope() / windowWidth; + offset = 255.0 * (windowCenter - GetRescaleIntercept()) / windowWidth + 127.5; + } + else + { + scaling = 255.0 * GetRescaleSlope() / windowWidth; + offset = 255.0 * (GetRescaleIntercept() - windowCenter) / windowWidth + 127.5; + } + } + + + void DicomImageInformation::ComputeRenderingTransform(double& offset, + double& scaling) const + { + double center, width; + GetDefaultWindowing(center, width); + ComputeRenderingTransform(offset, scaling, center, width); + } } diff -r 6e04db96fae3 -r 8964b391fe92 OrthancFramework/Sources/DicomFormat/DicomImageInformation.h --- a/OrthancFramework/Sources/DicomFormat/DicomImageInformation.h Fri Apr 04 22:12:35 2025 +0200 +++ b/OrthancFramework/Sources/DicomFormat/DicomImageInformation.h Sat Apr 05 12:56:02 2025 +0200 @@ -33,6 +33,31 @@ class ORTHANC_PUBLIC DicomImageInformation { private: + class Window + { + private: + double center_; + double width_; + + public: + Window(double center, + double width) : + center_(center), + width_(width) + { + } + + double GetCenter() const + { + return center_; + } + + double GetWidth() const + { + return width_; + } + }; + unsigned int width_; unsigned int height_; unsigned int samplesPerPixel_; @@ -48,6 +73,10 @@ PhotometricInterpretation photometric_; + double rescaleSlope_; + double rescaleIntercept_; + std::vector<Window> windows_; + protected: explicit DicomImageInformation() { @@ -99,5 +128,55 @@ static ValueRepresentation GuessPixelDataValueRepresentation(const DicomTransferSyntax& transferSyntax, unsigned int bitsAllocated); + + double GetRescaleSlope() const + { + return rescaleSlope_; + } + + double GetRescaleIntercept() const + { + return rescaleIntercept_; + } + + bool HasWindows() const + { + return !windows_.empty(); + } + + size_t GetWindowsCount() const + { + return windows_.size(); + } + + double GetWindowCenter(size_t index) const; + + double GetWindowWidth(size_t index) const; + + double ApplyRescale(double value) const; + + void GetDefaultWindowing(double& center, + double& width) const; + + /** + * Compute the linear transform "x * scaling + offset" that maps a + * window onto the [0,255] range of a grayscale image. The + * inversion due to MONOCHROME1 is taken into consideration. This + * information can be used in ImageProcessing::ShiftScale2(). + **/ + void ComputeRenderingTransform(double& offset, + double& scaling, + double windowCenter, + double windowWidth) const; + + void ComputeRenderingTransform(double& offset, + double& scaling, + size_t windowIndex) const + { + ComputeRenderingTransform(offset, scaling, GetWindowCenter(windowIndex), GetWindowWidth(windowIndex)); + } + + void ComputeRenderingTransform(double& offset, + double& scaling) const; // Use the default windowing }; } diff -r 6e04db96fae3 -r 8964b391fe92 OrthancFramework/Sources/DicomParsing/ParsedDicomFile.cpp --- a/OrthancFramework/Sources/DicomParsing/ParsedDicomFile.cpp Fri Apr 04 22:12:35 2025 +0200 +++ b/OrthancFramework/Sources/DicomParsing/ParsedDicomFile.cpp Sat Apr 05 12:56:02 2025 +0200 @@ -1935,7 +1935,7 @@ } windowWidth = static_cast<double>(1 << bitsStored); - windowCenter = windowWidth / 2.0f; + windowCenter = windowWidth / 2.0; } } diff -r 6e04db96fae3 -r 8964b391fe92 OrthancFramework/UnitTestsSources/FromDcmtkTests.cpp --- a/OrthancFramework/UnitTestsSources/FromDcmtkTests.cpp Fri Apr 04 22:12:35 2025 +0200 +++ b/OrthancFramework/UnitTestsSources/FromDcmtkTests.cpp Sat Apr 05 12:56:02 2025 +0200 @@ -3110,6 +3110,9 @@ TEST(ParsedDicomFile, ImageInformation) { + // If modifying this test, make sure to reflect the modification in + // "TEST(DicomImageInformation, FromDcmtkTests)" in file "ImageProcessingTests.cpp" + double wc, ww; double ri, rs; PhotometricInterpretation p; diff -r 6e04db96fae3 -r 8964b391fe92 OrthancFramework/UnitTestsSources/ImageProcessingTests.cpp --- a/OrthancFramework/UnitTestsSources/ImageProcessingTests.cpp Fri Apr 04 22:12:35 2025 +0200 +++ b/OrthancFramework/UnitTestsSources/ImageProcessingTests.cpp Sat Apr 05 12:56:02 2025 +0200 @@ -81,6 +81,176 @@ } +TEST(DicomImageInformation, Windowing) +{ + DicomMap m; + m.SetValue(DICOM_TAG_ROWS, "24", false); + m.SetValue(DICOM_TAG_COLUMNS, "16", false); + m.SetValue(DICOM_TAG_BITS_ALLOCATED, "16", false); + m.SetValue(DICOM_TAG_PHOTOMETRIC_INTERPRETATION, "MONOCHROME2", false); + + { + DicomImageInformation info(m); + ASSERT_DOUBLE_EQ(1.0, info.GetRescaleSlope()); + ASSERT_DOUBLE_EQ(0.0, info.GetRescaleIntercept()); + ASSERT_EQ(PhotometricInterpretation_Monochrome2, info.GetPhotometricInterpretation()); + ASSERT_EQ(0, info.GetWindowsCount()); + ASSERT_DOUBLE_EQ(14.0, info.ApplyRescale(14.0)); + } + + m.SetValue(DICOM_TAG_RESCALE_SLOPE, "10.25", false); + m.SetValue(DICOM_TAG_RESCALE_INTERCEPT, "-1.75", false); + m.SetValue(DICOM_TAG_PHOTOMETRIC_INTERPRETATION, "MONOCHROME1", false); + + { + DicomImageInformation info(m); + ASSERT_DOUBLE_EQ(10.25, info.GetRescaleSlope()); + ASSERT_DOUBLE_EQ(-1.75, info.GetRescaleIntercept()); + ASSERT_EQ(PhotometricInterpretation_Monochrome1, info.GetPhotometricInterpretation()); + ASSERT_FALSE(info.HasWindows()); + ASSERT_EQ(0, info.GetWindowsCount()); + ASSERT_THROW(info.GetWindowWidth(0), OrthancException); + ASSERT_THROW(info.GetWindowCenter(0), OrthancException); + ASSERT_DOUBLE_EQ(141.75, info.ApplyRescale(14.0)); + } + + m.SetValue(Orthanc::DICOM_TAG_WINDOW_CENTER, "10\\100\\1000", false); + m.SetValue(Orthanc::DICOM_TAG_WINDOW_WIDTH, "50\\60\\70", false); + + { + DicomImageInformation info(m); + ASSERT_TRUE(info.HasWindows()); + ASSERT_EQ(3u, info.GetWindowsCount()); + ASSERT_DOUBLE_EQ(10.0, info.GetWindowCenter(0)); + ASSERT_DOUBLE_EQ(50.0, info.GetWindowWidth(0)); + ASSERT_DOUBLE_EQ(100.0, info.GetWindowCenter(1)); + ASSERT_DOUBLE_EQ(60.0, info.GetWindowWidth(1)); + ASSERT_DOUBLE_EQ(1000.0, info.GetWindowCenter(2)); + ASSERT_DOUBLE_EQ(70.0, info.GetWindowWidth(2)); + ASSERT_THROW(info.GetWindowWidth(3), OrthancException); + ASSERT_THROW(info.GetWindowCenter(3), OrthancException); + } +} + + +TEST(DicomImageInformation, FromDcmtkTests) +{ + // This replicates TEST(ParsedDicomFile, ImageInformation) in + // "FromDcmtkTests.cpp", without the handling of frames and sequences + + DicomMap m; + m.SetValue(DICOM_TAG_ROWS, "24", false); + m.SetValue(DICOM_TAG_COLUMNS, "16", false); + m.SetValue(DICOM_TAG_BITS_ALLOCATED, "8", false); + + double wc, ww; + + { + DicomImageInformation info(m); + info.GetDefaultWindowing(wc, ww); + ASSERT_DOUBLE_EQ(128.0, wc); + ASSERT_DOUBLE_EQ(256.0, ww); + ASSERT_EQ(PhotometricInterpretation_Unknown, info.GetPhotometricInterpretation()); + ASSERT_DOUBLE_EQ(0.0, info.GetRescaleIntercept()); + ASSERT_DOUBLE_EQ(1.0, info.GetRescaleSlope()); + + double offset, scaling, x; + info.ComputeRenderingTransform(offset, scaling, -100, 200); + + x = -200; ASSERT_NEAR(0, x * scaling + offset, 0.000001); + x = -100; ASSERT_NEAR(127.5, x * scaling + offset, 0.000001); + x = 0; ASSERT_NEAR(255, x * scaling + offset, 0.000001); + } + + m.SetValue(DICOM_TAG_PHOTOMETRIC_INTERPRETATION, "MONOCHROME1", false); + + { + DicomImageInformation info(m); + info.GetDefaultWindowing(wc, ww); + ASSERT_EQ(PhotometricInterpretation_Monochrome1, info.GetPhotometricInterpretation()); + + double offset, scaling, x; + info.ComputeRenderingTransform(offset, scaling, -100, 200); + + x = -200; ASSERT_NEAR(255, x * scaling + offset, 0.000001); + x = -100; ASSERT_NEAR(127.5, x * scaling + offset, 0.000001); + x = 0; ASSERT_NEAR(0, x * scaling + offset, 0.000001); + } + + m.SetValue(DICOM_TAG_PHOTOMETRIC_INTERPRETATION, "MONOCHROME2", false); + m.SetValue(DICOM_TAG_RESCALE_SLOPE, "20", false); + m.SetValue(DICOM_TAG_RESCALE_INTERCEPT, "-100", false); + + { + DicomImageInformation info(m); + info.GetDefaultWindowing(wc, ww); + ASSERT_EQ(PhotometricInterpretation_Monochrome2, info.GetPhotometricInterpretation()); + + double offset, scaling, x; + info.ComputeRenderingTransform(offset, scaling, -100, 200); + + x = -5; ASSERT_NEAR(0, x * scaling + offset, 0.000001); + x = 0; ASSERT_NEAR(127.5, x * scaling + offset, 0.000001); + x = 5; ASSERT_NEAR(255, x * scaling + offset, 0.000001); + } + + m.SetValue(DICOM_TAG_PHOTOMETRIC_INTERPRETATION, "MONOCHROME1", false); + + { + DicomImageInformation info(m); + info.GetDefaultWindowing(wc, ww); + ASSERT_EQ(PhotometricInterpretation_Monochrome1, info.GetPhotometricInterpretation()); + + double offset, scaling, x; + info.ComputeRenderingTransform(offset, scaling, -100, 200); + + x = -5; ASSERT_NEAR(255, x * scaling + offset, 0.000001); + x = 0; ASSERT_NEAR(127.5, x * scaling + offset, 0.000001); + x = 5; ASSERT_NEAR(0, x * scaling + offset, 0.000001); + } + + m.SetValue(DICOM_TAG_PHOTOMETRIC_INTERPRETATION, "RGB", false); + m.SetValue(DICOM_TAG_BITS_STORED, "4", false); + + { + DicomImageInformation info(m); + info.GetDefaultWindowing(wc, ww); + ASSERT_DOUBLE_EQ(8.0, wc); + ASSERT_DOUBLE_EQ(16.0, ww); + ASSERT_EQ(PhotometricInterpretation_RGB, info.GetPhotometricInterpretation()); + } + + m.SetValue(DICOM_TAG_WINDOW_CENTER, "12", false); + m.SetValue(DICOM_TAG_WINDOW_WIDTH, "-22", false); + m.SetValue(DICOM_TAG_RESCALE_INTERCEPT, "-22", false); + m.SetValue(DICOM_TAG_RESCALE_SLOPE, "-23", false); + + { + DicomImageInformation info(m); + info.GetDefaultWindowing(wc, ww); + ASSERT_DOUBLE_EQ(12.0, wc); + ASSERT_DOUBLE_EQ(-22.0, ww); + ASSERT_DOUBLE_EQ(-22.0, info.GetRescaleIntercept()); + ASSERT_DOUBLE_EQ(-23.0, info.GetRescaleSlope()); + } + + m.Remove(DICOM_TAG_RESCALE_SLOPE); + m.Remove(DICOM_TAG_RESCALE_INTERCEPT); + m.SetValue(DICOM_TAG_WINDOW_CENTER, "12\\13\\14", false); + m.SetValue(DICOM_TAG_WINDOW_WIDTH, "-22\\-23\\-24", false); + m.SetValue(DICOM_TAG_RESCALE_INTERCEPT, "-22\\33\\34", false); + m.SetValue(DICOM_TAG_RESCALE_SLOPE, "-23\\-43\\-44", false); + + { + DicomImageInformation info(m); + info.GetDefaultWindowing(wc, ww); + ASSERT_DOUBLE_EQ(12.0, wc); + ASSERT_DOUBLE_EQ(-22.0, ww); + ASSERT_DOUBLE_EQ(0.0, info.GetRescaleIntercept()); + ASSERT_DOUBLE_EQ(1.0, info.GetRescaleSlope()); + } +} + namespace { diff -r 6e04db96fae3 -r 8964b391fe92 OrthancServer/Resources/ImplementationNotes/windowing.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/OrthancServer/Resources/ImplementationNotes/windowing.py Sat Apr 05 12:56:02 2025 +0200 @@ -0,0 +1,17 @@ +#!/usr/bin/env python3 + +import sympy + +x = sympy.symbols('x') +(rescaleSlope, rescaleIntercept) = sympy.symbols('rescaleSlope rescaleIntercept') +(windowCenter, windowWidth) = sympy.symbols('windowCenter windowWidth') + +t1 = rescaleSlope * x + rescaleIntercept + +# Slide 19 of Session 8 of LSINC1114 +low = windowCenter - windowWidth / 2.0 +high = windowCenter + windowWidth / 2.0 +t2 = 255.0 * (t1 - low) / (high - low) + +print('MONOCHROME1:', sympy.expand(255 - t2)) +print('MONOCHROME2:', sympy.expand(t2))