changeset 330:c42083d50ddf

Added support for DICOM tag "Recommended Absent Pixel CIELab" (0048,0015)
author Sebastien Jodogne <s.jodogne@gmail.com>
date Fri, 18 Oct 2024 13:08:55 +0200
parents ae2d769215d2
children cf828b381bc9
files Applications/CMakeLists.txt Applications/Dicomizer.cpp Framework/ColorSpaces.cpp Framework/ColorSpaces.h Framework/Inputs/DicomPyramid.cpp Framework/Inputs/DicomPyramid.h Framework/Inputs/DicomPyramidInstance.cpp Framework/Inputs/DicomPyramidInstance.h NEWS ViewerPlugin/CMakeLists.txt ViewerPlugin/Plugin.cpp ViewerPlugin/viewer.js
diffstat 12 files changed, 640 insertions(+), 3 deletions(-) [+]
line wrap: on
line diff
--- a/Applications/CMakeLists.txt	Fri Oct 18 08:48:53 2024 +0200
+++ b/Applications/CMakeLists.txt	Fri Oct 18 13:08:55 2024 +0200
@@ -107,6 +107,7 @@
   ${ORTHANC_WSI_DIR}/Framework/Algorithms/PyramidReader.cpp
   ${ORTHANC_WSI_DIR}/Framework/Algorithms/ReconstructPyramidCommand.cpp
   ${ORTHANC_WSI_DIR}/Framework/Algorithms/TranscodeTileCommand.cpp
+  ${ORTHANC_WSI_DIR}/Framework/ColorSpaces.cpp
   ${ORTHANC_WSI_DIR}/Framework/DicomToolbox.cpp
   ${ORTHANC_WSI_DIR}/Framework/DicomizerParameters.cpp
   ${ORTHANC_WSI_DIR}/Framework/Enumerations.cpp
--- a/Applications/Dicomizer.cpp	Fri Oct 18 08:48:53 2024 +0200
+++ b/Applications/Dicomizer.cpp	Fri Oct 18 13:08:55 2024 +0200
@@ -23,6 +23,7 @@
 
 #include "../Framework/Algorithms/ReconstructPyramidCommand.h"
 #include "../Framework/Algorithms/TranscodeTileCommand.h"
+#include "../Framework/ColorSpaces.h"
 #include "../Framework/DicomToolbox.h"
 #include "../Framework/DicomizerParameters.h"
 #include "../Framework/ImageToolbox.h"
@@ -54,6 +55,8 @@
 #include <dcmtk/dcmdata/dcvrobow.h>
 #include <dcmtk/dcmdata/dcvrat.h>
 
+#include <boost/math/special_functions/round.hpp>
+
 
 static const char* OPTION_COLOR = "color";
 static const char* OPTION_COMPRESSION = "compression";
@@ -563,6 +566,26 @@
   }
 
   SetupDimension(dataset, opticalPathId, source, volume);
+
+
+  // New in release 2.1
+  if (!dataset.tagExists(DCM_RecommendedAbsentPixelCIELabValue))
+  {
+    OrthancWSI::RGBColor rgb(parameters.GetBackgroundColorRed(),
+                             parameters.GetBackgroundColorGreen(),
+                             parameters.GetBackgroundColorBlue());
+    OrthancWSI::sRGBColor srgb(rgb);
+    OrthancWSI::XYZColor xyz(srgb);
+    OrthancWSI::LABColor lab(xyz);
+
+    uint16_t encoded[3];
+    lab.EncodeDicomRecommendedAbsentPixelCIELab(encoded);
+
+    if (!dataset.putAndInsertUint16Array(DCM_RecommendedAbsentPixelCIELabValue, encoded, 3).good())
+    {
+      throw Orthanc::OrthancException(Orthanc::ErrorCode_InternalError);
+    }
+  }
 }
 
 
@@ -1174,6 +1197,8 @@
 }
 
 
+#include "../Framework/ColorSpaces.h"
+
 int main(int argc, char* argv[])
 {
   OrthancWSI::ApplicationToolbox::GlobalInitialize();
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/Framework/ColorSpaces.cpp	Fri Oct 18 13:08:55 2024 +0200
@@ -0,0 +1,274 @@
+/**
+ * Orthanc - A Lightweight, RESTful DICOM Store
+ * Copyright (C) 2012-2016 Sebastien Jodogne, Medical Physics
+ * Department, University Hospital of Liege, Belgium
+ * Copyright (C) 2017-2023 Osimis S.A., Belgium
+ * Copyright (C) 2024-2024 Orthanc Team SRL, Belgium
+ * Copyright (C) 2021-2024 Sebastien Jodogne, ICTEAM UCLouvain, Belgium
+ *
+ * This program is free software: you can redistribute it and/or
+ * modify it under the terms of the GNU Affero General Public License
+ * as published by the Free Software Foundation, either version 3 of
+ * the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful, but
+ * WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Affero General Public License for more details.
+ * 
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ **/
+
+
+#include "ColorSpaces.h"
+
+#include <SerializationToolbox.h>
+#include <Toolbox.h>
+
+#include <boost/math/special_functions/round.hpp>
+
+
+namespace OrthancWSI
+{
+  RGBColor::RGBColor(const sRGBColor& srgb)
+  {
+    if (srgb.GetR() < 0)
+    {
+      r_ = 0;
+    }
+    else if (srgb.GetR() >= 1)
+    {
+      r_ = 255;
+    }
+    else
+    {
+      r_ = boost::math::iround(srgb.GetR() * 255.0f);
+    }
+
+    if (srgb.GetG() < 0)
+    {
+      g_ = 0;
+    }
+    else if (srgb.GetG() >= 1)
+    {
+      g_ = 255;
+    }
+    else
+    {
+      g_ = boost::math::iround(srgb.GetG() * 255.0f);
+    }
+
+    if (srgb.GetB() < 0)
+    {
+      b_ = 0;
+    }
+    else if (srgb.GetB() >= 1)
+    {
+      b_ = 255;
+    }
+    else
+    {
+      b_ = boost::math::iround(srgb.GetB() * 255.0f);
+    }
+  }
+
+
+  sRGBColor::sRGBColor(const RGBColor& rgb)
+  {
+    r_ = static_cast<float>(rgb.GetR()) / 255.0f;
+    g_ = static_cast<float>(rgb.GetG()) / 255.0f;
+    b_ = static_cast<float>(rgb.GetB()) / 255.0f;
+  }
+
+
+  static float ApplyGammaXYZ(float value)
+  {
+    // https://www.image-engineering.de/library/technotes/958-how-to-convert-between-srgb-and-ciexyz
+    if (value <= 0.0031308f)
+    {
+      return value * 12.92f;
+    }
+    else
+    {
+      return 1.055f * powf(value, 1.0f / 2.4f) - 0.055f;
+    }
+  }
+
+
+  sRGBColor::sRGBColor(const XYZColor& xyz)
+  {
+    // https://en.wikipedia.org/wiki/SRGB#From_CIE_XYZ_to_sRGB
+    // https://www.image-engineering.de/library/technotes/958-how-to-convert-between-srgb-and-ciexyz
+    const float sr =  3.2404542f * xyz.GetX() - 1.5371385f * xyz.GetY() - 0.4985314f * xyz.GetZ();
+    const float sg = -0.9692660f * xyz.GetX() + 1.8760108f * xyz.GetY() + 0.0415560f * xyz.GetZ();
+    const float sb =  0.0556434f * xyz.GetX() - 0.2040259f * xyz.GetY() + 1.0572252f * xyz.GetZ();
+
+    r_ = ApplyGammaXYZ(sr);
+    g_ = ApplyGammaXYZ(sg);
+    b_ = ApplyGammaXYZ(sb);
+  }
+
+
+  static float LinearizeXYZ(float value)
+  {
+    // https://www.image-engineering.de/library/technotes/958-how-to-convert-between-srgb-and-ciexyz
+    if (value <= 0.04045f)
+    {
+      return value / 12.92f;
+    }
+    else
+    {
+      return powf((value + 0.055f) / 1.055f, 2.4f);
+    }
+  }
+
+
+  XYZColor::XYZColor(const sRGBColor& srgb)
+  {
+    // https://en.wikipedia.org/wiki/SRGB#From_sRGB_to_CIE_XYZ
+    // https://www.image-engineering.de/library/technotes/958-how-to-convert-between-srgb-and-ciexyz
+    const float linearizedR = LinearizeXYZ(srgb.GetR());
+    const float linearizedG = LinearizeXYZ(srgb.GetG());
+    const float linearizedB = LinearizeXYZ(srgb.GetB());
+
+    x_ = 0.4124564f * linearizedR + 0.3575761f * linearizedG + 0.1804375f * linearizedB;
+    y_ = 0.2126729f * linearizedR + 0.7151522f * linearizedG + 0.0721750f * linearizedB;
+    z_ = 0.0193339f * linearizedR + 0.1191920f * linearizedG + 0.9503041f * linearizedB;
+  }
+
+
+  static const float LAB_DELTA = 6.0f / 29.0f;
+
+  static float LABf_inv(float t)
+  {
+    if (t > LAB_DELTA)
+    {
+      return powf(t, 3.0f);
+    }
+    else
+    {
+      return 3.0f * LAB_DELTA * LAB_DELTA * (t - 4.0f / 29.0f);
+    }
+  }
+
+
+  // Those correspond to Standard Illuminant D65
+  // https://en.wikipedia.org/wiki/CIELAB_color_space#From_CIEXYZ_to_CIELAB
+  static const float X_N = 95.0489f;
+  static const float Y_N = 100.0f;
+  static const float Z_N = 108.8840f;
+
+  XYZColor::XYZColor(const LABColor& lab)
+  {
+    // https://en.wikipedia.org/wiki/CIELAB_color_space#From_CIELAB_to_CIEXYZ
+    const float shared = (lab.GetL() + 16.0f) / 116.0f;
+
+    x_ = X_N * LABf_inv(shared + lab.GetA() / 500.0f) / 100.0f;
+    y_ = Y_N * LABf_inv(shared) / 100.0f;
+    z_ = Z_N * LABf_inv(shared - lab.GetB() / 200.0f) / 100.0f;
+  }
+
+
+  static float LABf(float t)
+  {
+    if (t > powf(LAB_DELTA, 3.0f))
+    {
+      return powf(t, 1.0f / 3.0f);
+    }
+    else
+    {
+      return t / 3.0f * powf(LAB_DELTA, -2.0f) + 4.0f / 29.0f;
+    }
+  }
+
+
+  LABColor::LABColor(const XYZColor& xyz)
+  {
+    // https://en.wikipedia.org/wiki/CIELAB_color_space#From_CIEXYZ_to_CIELAB
+
+    const float fx = LABf(xyz.GetX() * 100.0f / X_N);
+    const float fy = LABf(xyz.GetY() * 100.0f / Y_N);
+    const float fz = LABf(xyz.GetZ() * 100.0f / Z_N);
+
+    l_ = 116.0f * fy - 16.0f;
+    a_ = 500.0f * (fx - fy);
+    b_ = 200.0f * (fy - fz);
+  }
+
+
+  static uint16_t EncodeUint16(float value,
+                               float minValue,
+                               float maxValue)
+  {
+    if (value <= minValue)
+    {
+      return 0;
+    }
+    else if (value >= maxValue)
+    {
+      return 0xffff;
+    }
+    else
+    {
+      float lambda = (value - minValue) / (maxValue - minValue);
+      assert(lambda >= 0 && lambda <= 1);
+      return static_cast<uint16_t>(boost::math::iround(lambda * static_cast<float>(0xffff)));
+    }
+  }
+
+
+  void LABColor::EncodeDicomRecommendedAbsentPixelCIELab(uint16_t target[3]) const
+  {
+    /**
+     * "An L value linearly scaled to 16 bits, such that 0x0000
+     * corresponds to an L of 0.0, and 0xFFFF corresponds to an L of
+     * 100.0."
+     **/
+    target[0] = EncodeUint16(GetL(), 0.0f, 100.0f);
+
+    /**
+     * "An a* then a b* value, each linearly scaled to 16 bits and
+     * offset to an unsigned range, such that 0x0000 corresponds to an
+     * a* or b* of -128.0, 0x8080 corresponds to an a* or b* of 0.0
+     * and 0xFFFF corresponds to an a* or b* of 127.0"
+     **/
+    target[1] = EncodeUint16(GetA(), -128.0f, 127.0f);
+    target[2] = EncodeUint16(GetB(), -128.0f, 127.0f);
+  }
+
+
+  LABColor LABColor::DecodeDicomRecommendedAbsentPixelCIELab(uint16_t l,
+                                                             uint16_t a,
+                                                             uint16_t b)
+  {
+    return LABColor(static_cast<float>(l) / static_cast<float>(0xffff) * 100.0f,
+                    -128.0f + static_cast<float>(a) / static_cast<float>(0xffff) * 255.0f,
+                    -128.0f + static_cast<float>(b) / static_cast<float>(0xffff) * 255.0f);
+  }
+
+
+  bool LABColor::DecodeDicomRecommendedAbsentPixelCIELab(LABColor& target,
+                                                         const std::string& tag)
+  {
+    std::vector<std::string> channels;
+    Orthanc::Toolbox::TokenizeString(channels, tag, '\\');
+
+    unsigned int l, a, b;
+    if (channels.size() == 3 &&
+        Orthanc::SerializationToolbox::ParseUnsignedInteger32(l, channels[0]) &&
+        Orthanc::SerializationToolbox::ParseUnsignedInteger32(a, channels[1]) &&
+        Orthanc::SerializationToolbox::ParseUnsignedInteger32(b, channels[2]) &&
+        l <= 0xffffu &&
+        a <= 0xffffu &&
+        b <= 0xffffu)
+    {
+      target = LABColor::DecodeDicomRecommendedAbsentPixelCIELab(l, a, b);
+      return true;
+    }
+    else
+    {
+      return false;
+    }
+  }
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/Framework/ColorSpaces.h	Fri Oct 18 13:08:55 2024 +0200
@@ -0,0 +1,192 @@
+/**
+ * Orthanc - A Lightweight, RESTful DICOM Store
+ * Copyright (C) 2012-2016 Sebastien Jodogne, Medical Physics
+ * Department, University Hospital of Liege, Belgium
+ * Copyright (C) 2017-2023 Osimis S.A., Belgium
+ * Copyright (C) 2024-2024 Orthanc Team SRL, Belgium
+ * Copyright (C) 2021-2024 Sebastien Jodogne, ICTEAM UCLouvain, Belgium
+ *
+ * This program is free software: you can redistribute it and/or
+ * modify it under the terms of the GNU Affero General Public License
+ * as published by the Free Software Foundation, either version 3 of
+ * the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful, but
+ * WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Affero General Public License for more details.
+ * 
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ **/
+
+
+#pragma once
+
+#include <stdint.h>
+#include <string>
+
+
+namespace OrthancWSI
+{
+  class sRGBColor;
+  class XYZColor;
+  class LABColor;
+
+
+  class RGBColor
+  {
+  private:
+    uint8_t  r_;
+    uint8_t  g_;
+    uint8_t  b_;
+
+  public:
+    RGBColor(uint8_t r,
+             uint8_t g,
+             uint8_t b) :
+      r_(r),
+      g_(g),
+      b_(b)
+    {
+    }
+
+    RGBColor(const sRGBColor& srgb);
+
+    uint8_t GetR() const
+    {
+      return r_;
+    }
+
+    uint8_t GetG() const
+    {
+      return g_;
+    }
+
+    uint8_t GetB() const
+    {
+      return b_;
+    }
+  };
+
+
+  // Those correspond to Standard Illuminant D65
+  // https://en.wikipedia.org/wiki/SRGB#From_CIE_XYZ_to_sRGB
+  class sRGBColor
+  {
+  private:
+    float  r_;
+    float  g_;
+    float  b_;
+
+  public:
+    sRGBColor(float r,
+              float g,
+              float b) :
+      r_(r),
+      g_(g),
+      b_(b)
+    {
+    }
+
+    sRGBColor(const RGBColor& rgb);
+
+    sRGBColor(const XYZColor& xyz);
+
+    float GetR() const
+    {
+      return r_;
+    }
+
+    float GetG() const
+    {
+      return g_;
+    }
+
+    float GetB() const
+    {
+      return b_;
+    }
+  };
+
+
+  class XYZColor
+  {
+  private:
+    float  x_;
+    float  y_;
+    float  z_;
+
+  public:
+    XYZColor(const sRGBColor& srgb);
+
+    XYZColor(const LABColor& lab);
+
+    float GetX() const
+    {
+      return x_;
+    }
+
+    float GetY() const
+    {
+      return y_;
+    }
+
+    float GetZ() const
+    {
+      return z_;
+    }
+  };
+
+
+  class LABColor
+  {
+  private:
+    float  l_;
+    float  a_;
+    float  b_;
+
+  public:
+    LABColor() :
+      l_(0),
+      a_(0),
+      b_(0)
+    {
+    }
+
+    LABColor(float l,
+             float a,
+             float b) :
+      l_(l),
+      a_(a),
+      b_(b)
+    {
+    }
+
+    LABColor(const XYZColor& xyz);
+
+    float GetL() const
+    {
+      return l_;
+    }
+
+    float GetA() const
+    {
+      return a_;
+    }
+
+    float GetB() const
+    {
+      return b_;
+    }
+
+    void EncodeDicomRecommendedAbsentPixelCIELab(uint16_t target[3]) const;
+
+    static LABColor DecodeDicomRecommendedAbsentPixelCIELab(uint16_t l,
+                                                            uint16_t a,
+                                                            uint16_t b);
+
+    static bool DecodeDicomRecommendedAbsentPixelCIELab(LABColor& target,
+                                                        const std::string& tag);
+  };
+}
--- a/Framework/Inputs/DicomPyramid.cpp	Fri Oct 18 08:48:53 2024 +0200
+++ b/Framework/Inputs/DicomPyramid.cpp	Fri Oct 18 13:08:55 2024 +0200
@@ -103,8 +103,15 @@
             (tokens[1] != "THUMBNAIL" &&
              tokens[1] != "OVERVIEW"))
         {
+          if (instance->HasBackgroundColor())
+          {
+            backgroundRed_ = instance->GetBackgroundRed();
+            backgroundGreen_ = instance->GetBackgroundGreen();
+            backgroundBlue_ = instance->GetBackgroundBlue();
+          }
+
           instances_.push_back(instance.release());
-        }        
+        }
       }
       catch (Orthanc::OrthancException&)
       {
@@ -157,7 +164,10 @@
                              const std::string& seriesId,
                              bool useCache) :
     orthanc_(orthanc),
-    seriesId_(seriesId)
+    seriesId_(seriesId),
+    backgroundRed_(255),
+    backgroundGreen_(255),
+    backgroundBlue_(255)
   {
     RegisterInstances(seriesId, useCache);
 
--- a/Framework/Inputs/DicomPyramid.h	Fri Oct 18 08:48:53 2024 +0200
+++ b/Framework/Inputs/DicomPyramid.h	Fri Oct 18 13:08:55 2024 +0200
@@ -38,6 +38,9 @@
     std::string                         seriesId_;
     std::vector<DicomPyramidInstance*>  instances_;
     std::vector<DicomPyramidLevel*>     levels_;
+    uint8_t                             backgroundRed_;
+    uint8_t                             backgroundGreen_;
+    uint8_t                             backgroundBlue_;
 
     void Clear();
 
@@ -85,5 +88,20 @@
     virtual Orthanc::PixelFormat GetPixelFormat() const ORTHANC_OVERRIDE;
 
     virtual Orthanc::PhotometricInterpretation GetPhotometricInterpretation() const ORTHANC_OVERRIDE;
+
+    uint8_t GetBackgroundRed() const
+    {
+      return backgroundRed_;
+    }
+
+    uint8_t GetBackgroundGreen() const
+    {
+      return backgroundGreen_;
+    }
+
+    uint8_t GetBackgroundBlue() const
+    {
+      return backgroundBlue_;
+    }
   };
 }
--- a/Framework/Inputs/DicomPyramidInstance.cpp	Fri Oct 18 08:48:53 2024 +0200
+++ b/Framework/Inputs/DicomPyramidInstance.cpp	Fri Oct 18 13:08:55 2024 +0200
@@ -24,6 +24,7 @@
 #include "../PrecompiledHeadersWSI.h"
 #include "DicomPyramidInstance.h"
 
+#include "../ColorSpaces.h"
 #include "../DicomToolbox.h"
 #include "../../Resources/Orthanc/Stone/DicomDatasetReader.h"
 #include "../../Resources/Orthanc/Stone/FullOrthancDataset.h"
@@ -51,6 +52,7 @@
   static const Orthanc::DicomTag DICOM_TAG_TOTAL_PIXEL_MATRIX_COLUMNS(0x0048, 0x0006);
   static const Orthanc::DicomTag DICOM_TAG_TOTAL_PIXEL_MATRIX_ROWS(0x0048, 0x0007);
   static const Orthanc::DicomTag DICOM_TAG_IMAGE_TYPE(0x0008, 0x0008);
+  static const Orthanc::DicomTag DICOM_TAG_RECOMMENDED_ABSENT_PIXEL_CIELAB(0x0048, 0x0015);
 
   static ImageCompression DetectImageCompression(OrthancStone::IOrthancConnection& orthanc,
                                                  const std::string& instanceId)
@@ -263,6 +265,23 @@
         frames_[i].second = i / w;
       }
     }
+
+    // New in WSI 2.1
+    std::string background;
+    if (dataset.GetStringValue(background, Orthanc::DicomPath(DICOM_TAG_RECOMMENDED_ABSENT_PIXEL_CIELAB)))
+    {
+      LABColor lab;
+      if (LABColor::DecodeDicomRecommendedAbsentPixelCIELab(lab, background))
+      {
+        XYZColor xyz(lab);
+        sRGBColor srgb(xyz);
+        RGBColor rgb(srgb);
+        hasBackgroundColor_ = true;
+        backgroundRed_ = rgb.GetR();
+        backgroundGreen_ = rgb.GetG();
+        backgroundBlue_ = rgb.GetB();
+      }
+    }
   }
 
 
@@ -271,7 +290,11 @@
                                              bool useCache) :
     instanceId_(instanceId),
     hasCompression_(false),
-    compression_(ImageCompression_None)  // Dummy initialization for serialization
+    compression_(ImageCompression_None),  // Dummy initialization for serialization
+    hasBackgroundColor_(false),
+    backgroundRed_(0),
+    backgroundGreen_(0),
+    backgroundBlue_(0)
   {
     if (useCache)
     {
@@ -327,6 +350,7 @@
   static const char* const TOTAL_HEIGHT = "TotalHeight";
   static const char* const PHOTOMETRIC_INTERPRETATION = "PhotometricInterpretation";
   static const char* const IMAGE_TYPE = "ImageType";
+  static const char* const BACKGROUND_COLOR = "BackgroundColor";
   
   
   void DicomPyramidInstance::Serialize(std::string& result) const
@@ -355,6 +379,15 @@
     content[PHOTOMETRIC_INTERPRETATION] = Orthanc::EnumerationToString(photometric_);
     content[IMAGE_TYPE] = imageType_;
 
+    if (hasBackgroundColor_)
+    {
+      Json::Value color = Json::arrayValue;
+      color.append(backgroundRed_);
+      color.append(backgroundGreen_);
+      color.append(backgroundBlue_);
+      content[BACKGROUND_COLOR] = color;
+    }
+
 #if ORTHANC_FRAMEWORK_VERSION_IS_ABOVE(1, 9, 0)
     Orthanc::Toolbox::WriteFastJson(result, content);
 #else
@@ -407,5 +440,61 @@
       frames_[i].first = f[i][0].asInt();
       frames_[i].second = f[i][1].asInt();
     }
+
+    hasBackgroundColor_ = false;
+    if (content.isMember(BACKGROUND_COLOR))
+    {
+      const Json::Value& color = content[BACKGROUND_COLOR];
+      if (color.type() == Json::arrayValue &&
+          color.size() == 3u &&
+          color[0].isUInt() &&
+          color[1].isUInt() &&
+          color[2].isUInt())
+      {
+        hasBackgroundColor_ = true;
+        backgroundRed_ = color[0].asUInt();
+        backgroundGreen_ = color[1].asUInt();
+        backgroundBlue_ = color[2].asUInt();
+      }
+    }
+  }
+
+
+  uint8_t DicomPyramidInstance::GetBackgroundRed() const
+  {
+    if (hasBackgroundColor_)
+    {
+      return backgroundRed_;
+    }
+    else
+    {
+      throw Orthanc::OrthancException(Orthanc::ErrorCode_BadSequenceOfCalls);
+    }
+  }
+
+
+  uint8_t DicomPyramidInstance::GetBackgroundGreen() const
+  {
+    if (hasBackgroundColor_)
+    {
+      return backgroundGreen_;
+    }
+    else
+    {
+      throw Orthanc::OrthancException(Orthanc::ErrorCode_BadSequenceOfCalls);
+    }
+  }
+
+
+  uint8_t DicomPyramidInstance::GetBackgroundBlue() const
+  {
+    if (hasBackgroundColor_)
+    {
+      return backgroundBlue_;
+    }
+    else
+    {
+      throw Orthanc::OrthancException(Orthanc::ErrorCode_BadSequenceOfCalls);
+    }
   }
 }
--- a/Framework/Inputs/DicomPyramidInstance.h	Fri Oct 18 08:48:53 2024 +0200
+++ b/Framework/Inputs/DicomPyramidInstance.h	Fri Oct 18 13:08:55 2024 +0200
@@ -47,6 +47,10 @@
     std::vector<FrameLocation>          frames_;
     Orthanc::PhotometricInterpretation  photometric_;
     std::string                         imageType_;
+    bool                                hasBackgroundColor_;
+    uint8_t                             backgroundRed_;
+    uint8_t                             backgroundGreen_;
+    uint8_t                             backgroundBlue_;
 
     void Load(OrthancStone::IOrthancConnection&  orthanc,
               const std::string& instanceId);
@@ -110,5 +114,16 @@
     unsigned int GetFrameLocationY(size_t frame) const;
 
     void Serialize(std::string& result) const;
+
+    bool HasBackgroundColor() const
+    {
+      return hasBackgroundColor_;
+    }
+
+    uint8_t GetBackgroundRed() const;
+
+    uint8_t GetBackgroundGreen() const;
+
+    uint8_t GetBackgroundBlue() const;
   };
 }
--- a/NEWS	Fri Oct 18 08:48:53 2024 +0200
+++ b/NEWS	Fri Oct 18 13:08:55 2024 +0200
@@ -5,6 +5,7 @@
 * OrthancWSIDicomizer supports plain TIFF, besides hierarchical TIFF
 * New option: "--force-openslide" to force the use of OpenSlide on TIFF-like files
 * New option: "--padding" to control deep zoom of plain PNG/JPEG/TIFF images over IIIF
+* Added support for DICOM tag "Recommended Absent Pixel CIELab" (0048,0015)
 * Force version of Mirador to 3.3.0
 * In the IIIF manifest, reverse the order of the "sizes" field, which
   seems to fix compatibility with Mirador v4.0.0-alpha
--- a/ViewerPlugin/CMakeLists.txt	Fri Oct 18 08:48:53 2024 +0200
+++ b/ViewerPlugin/CMakeLists.txt	Fri Oct 18 13:08:55 2024 +0200
@@ -191,6 +191,7 @@
   Plugin.cpp
   RawTile.cpp
 
+  ${ORTHANC_WSI_DIR}/Framework/ColorSpaces.cpp
   ${ORTHANC_WSI_DIR}/Framework/DicomToolbox.cpp
   ${ORTHANC_WSI_DIR}/Framework/Enumerations.cpp
   ${ORTHANC_WSI_DIR}/Framework/ImageToolbox.cpp
--- a/ViewerPlugin/Plugin.cpp	Fri Oct 18 08:48:53 2024 +0200
+++ b/ViewerPlugin/Plugin.cpp	Fri Oct 18 13:08:55 2024 +0200
@@ -106,6 +106,15 @@
   result["TotalHeight"] = totalHeight;
   result["TotalWidth"] = totalWidth;
 
+  {
+    // New in WSI 2.1
+    char tmp[16];
+    sprintf(tmp, "#%02x%02x%02x", locker.GetPyramid().GetBackgroundRed(),
+            locker.GetPyramid().GetBackgroundGreen(),
+            locker.GetPyramid().GetBackgroundBlue());
+    result["BackgroundColor"] = tmp;
+  }
+
   std::string s = result.toStyledString();
   OrthancPluginAnswerBuffer(OrthancPlugins::GetGlobalContext(), output, s.c_str(), s.size(), "application/json");
 }
--- a/ViewerPlugin/viewer.js	Fri Oct 18 08:48:53 2024 +0200
+++ b/ViewerPlugin/viewer.js	Fri Oct 18 13:08:55 2024 +0200
@@ -63,6 +63,8 @@
         alert('Error - Cannot get the pyramid structure of series: ' + seriesId);
       },
       success : function(series) {
+        $('#map').css('background', series['BackgroundColor']);  // New in WSI 2.1
+
         var width = series['TotalWidth'];
         var height = series['TotalHeight'];
         var countLevels = series['Resolutions'].length;