# 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))