changeset 2173:4596ad1b2aa4 dicom-sr

integration mainline->dicom-sr
author Sebastien Jodogne <s.jodogne@gmail.com>
date Tue, 22 Oct 2024 15:56:08 +0200
parents e65fe2e50fde (current diff) 239fb2c893c1 (diff)
children
files Applications/StoneWebViewer/WebAssembly/StoneWebViewer.cpp
diffstat 18 files changed, 619 insertions(+), 269 deletions(-) [+]
line wrap: on
line diff
--- a/Applications/StoneWebViewer/NEWS	Fri Sep 27 22:34:17 2024 +0200
+++ b/Applications/StoneWebViewer/NEWS	Tue Oct 22 15:56:08 2024 +0200
@@ -1,6 +1,12 @@
 Pending changes in the mainline
 ===============================
 
+* Automatically stretch to whole range for images without preset
+* Improved support of the (0028,9132) tag for Philips multiframe images
+* Remember the previous layout when re-opening the viewer.
+* Added a Print button in the PDF viewer toolbar.
+* Added a Download button in the PDF viewer toolbar.
+ 
 
 Version 2.6 (2024-08-31)
 ========================
--- a/Applications/StoneWebViewer/Plugin/Plugin.cpp	Fri Sep 27 22:34:17 2024 +0200
+++ b/Applications/StoneWebViewer/Plugin/Plugin.cpp	Tue Oct 22 15:56:08 2024 +0200
@@ -61,7 +61,7 @@
       }
 
       std::string version = info["Version"].asString();
-      if (version != "mainline")
+      if (version.find("mainline") != 0)
       {
         std::vector<std::string> tokens;
         Orthanc::Toolbox::TokenizeString(tokens, version, '.');
--- a/Applications/StoneWebViewer/WebApplication/app.js	Fri Sep 27 22:34:17 2024 +0200
+++ b/Applications/StoneWebViewer/WebApplication/app.js	Tue Oct 22 15:56:08 2024 +0200
@@ -960,7 +960,7 @@
         this.layoutCountX = 1;
         this.layoutCountY = 2;
       }
-
+      localStorage.setItem('layout', layout);
       this.FitContent();
     },
 
@@ -1369,7 +1369,11 @@
   mounted: function() {
     // Warning: In this function, the "stone" global object is not initialized yet!
     
-    this.SetViewportLayout('1x1');
+    if (localStorage.layout) {
+      this.SetViewportLayout(localStorage.layout);
+    } else {
+      this.SetViewportLayout('1x1');
+    }
 
     if (localStorage.settingNotDiagnostic) {
       this.settingNotDiagnostic = (localStorage.settingNotDiagnostic == '1');
--- a/Applications/StoneWebViewer/WebApplication/index.html	Fri Sep 27 22:34:17 2024 +0200
+++ b/Applications/StoneWebViewer/WebApplication/index.html	Tue Oct 22 15:56:08 2024 +0200
@@ -898,6 +898,14 @@
 
         <div class="wv-overlay">
           <div class="wv-overlay-bottomleft wvPrintExclude">
+            <button class="btn btn-primary" @click="Download()"
+                    data-toggle="tooltip" data-title="Download">
+              <i class="fa fa-download"></i>
+            </button>
+            <button class="btn btn-primary" @click="Print()"
+                    data-toggle="tooltip" data-title="Print">
+              <i class="fa fa-print"></i>
+            </button>
             <button class="btn btn-primary" @click="FitWidth()"
                     data-toggle="tooltip" data-title="Fit page width">
               <i class="fas fa-text-width"></i>
--- a/Applications/StoneWebViewer/WebApplication/pdf-viewer.js	Fri Sep 27 22:34:17 2024 +0200
+++ b/Applications/StoneWebViewer/WebApplication/pdf-viewer.js	Tue Oct 22 15:56:08 2024 +0200
@@ -92,6 +92,46 @@
     });
   },
   methods: {
+    Download: function() {
+      if (this.pdfDoc !== null) {
+        const blob = new Blob([this.pdf], { type: 'application/pdf'});
+        const blobUrl = URL.createObjectURL(blob);
+
+        const a = document.createElement('a');
+        a.href = blobUrl;
+        a.download = "report.pdf";
+
+        document.body.appendChild(a);
+        a.click();
+        document.body.removeChild(a);
+
+        // Revoke the object URL to free up memory
+        URL.revokeObjectURL(blobUrl);
+      }
+    },
+    Print: function() {
+      if (this.pdfDoc !== null) {
+        if (0) { // works on Chrome but with a popup that is blocked by default !
+          const blob = new Blob([this.pdf], { type: 'application/pdf'});
+          const blobUrl = URL.createObjectURL(blob);
+
+          let w = window.open(blobUrl, '_blank');
+          w.print();
+        } else {
+          // Let's open a new window with the pdf
+          // First we need to convert the pdf from a byte array to a binary string and then to b64
+          let binaryStringPdf = '';
+          for (let i = 0; i < this.pdf.length; i++) {
+            binaryStringPdf += String.fromCharCode(this.pdf[i]);
+          }
+
+          const htmlContent = '<html><body style="margin: 0;"><embed width="100%" height="100%" src="data:application/pdf;base64,' + btoa(binaryStringPdf) + '" type="application/pdf" /></body></html>';
+
+          let w = window.open('', '_blank');
+          w.document.write(htmlContent);
+        }
+      }
+    },
     NextPage: function() {
       if (this.pdfDoc !== null &&
           this.currentPage < this.pdfDoc.numPages) {
--- a/Applications/StoneWebViewer/WebAssembly/StoneWebViewer.cpp	Fri Sep 27 22:34:17 2024 +0200
+++ b/Applications/StoneWebViewer/WebAssembly/StoneWebViewer.cpp	Tue Oct 22 15:56:08 2024 +0200
@@ -121,7 +121,8 @@
     ThumbnailType_Pdf,
     ThumbnailType_Video,
     ThumbnailType_Loading,
-    ThumbnailType_Unknown
+    ThumbnailType_Unknown,
+    ThumbnailType_Unavailable
     };
 
 
@@ -211,6 +212,76 @@
 
 
 
+enum WindowingState
+{
+  WindowingState_None = 1,
+  WindowingState_Fallback = 2,
+  WindowingState_GlobalPreset = 3,
+  WindowingState_FramePreset = 4,
+  WindowingState_User = 5
+};
+
+
+class WindowingTracker
+{
+private:
+  WindowingState           state_;
+  OrthancStone::Windowing  windowing_;
+
+public:
+  WindowingTracker() :
+    state_(WindowingState_None)
+  {
+  }
+
+  WindowingState GetState() const
+  {
+    return state_;
+  }
+
+  const OrthancStone::Windowing& GetWindowing() const
+  {
+    return windowing_;
+  }
+
+  void Reset()
+  {
+    state_ = WindowingState_None;
+    windowing_ = OrthancStone::Windowing();
+  }
+
+  // Returns "true" iif. the windowing needed an update
+  bool Update(WindowingState newState,
+              const OrthancStone::Windowing& newWindowing)
+  {
+    if (newState == WindowingState_None)
+    {
+      // "Reset()" should have been called
+      throw Orthanc::OrthancException(Orthanc::ErrorCode_ParameterOutOfRange);
+    }
+
+    if (newState >= state_)
+    {
+      state_ = newState;
+
+      if (windowing_.IsNear(newWindowing))
+      {
+        return false;
+      }
+      else
+      {
+        windowing_ = newWindowing;
+        return true;
+      }
+    }
+    else
+    {
+      return false;
+    }
+  }
+};
+
+
 class IFramesCollection : public boost::noncopyable
 {
 public:
@@ -1535,6 +1606,79 @@
 
 
 
+class InstancesCache : public boost::noncopyable
+{
+private:
+  // Maps "SOP Instance UID" to DICOM parameters
+  typedef std::map<std::string, OrthancStone::DicomInstanceParameters*>  Content;
+
+  Content  content_;
+
+  void Clear()
+  {
+    for (Content::iterator it = content_.begin(); it != content_.end(); ++it)
+    {
+      assert(it->second != NULL);
+      delete it->second;
+    }
+
+    content_.clear();
+  }
+
+public:
+  ~InstancesCache()
+  {
+    Clear();
+  }
+
+  void Store(const std::string& sopInstanceUid,
+             const OrthancStone::DicomInstanceParameters& parameters)
+  {
+    Content::iterator found = content_.find(sopInstanceUid);
+    if (found == content_.end())
+    {
+      content_[sopInstanceUid] = parameters.Clone();
+    }
+  }
+
+  class Accessor : public boost::noncopyable
+  {
+  private:
+    std::unique_ptr<OrthancStone::DicomInstanceParameters>  parameters_;
+
+  public:
+    Accessor(InstancesCache& that,
+             const std::string& sopInstanceUid)
+    {
+      Content::iterator found = that.content_.find(sopInstanceUid);
+      if (found != that.content_.end())
+      {
+        assert(found->second != NULL);
+        parameters_.reset(found->second->Clone());
+      }
+    }
+
+    bool IsValid() const
+    {
+      return parameters_.get() != NULL;
+    }
+
+    const OrthancStone::DicomInstanceParameters& GetParameters() const
+    {
+      if (IsValid())
+      {
+        return *parameters_;
+      }
+      else
+      {
+        throw Orthanc::OrthancException(Orthanc::ErrorCode_BadSequenceOfCalls);
+      }
+    }
+  };
+};
+
+
+
 class SeriesCursor : public boost::noncopyable
 {
 public:
@@ -2078,8 +2222,7 @@
                                             const OrthancStone::Vector& normal) = 0;
 
     virtual void SignalWindowingUpdated(const ViewerViewport& viewport,
-                                        double windowingCenter,
-                                        double windowingWidth) = 0;
+                                        const OrthancStone::Windowing& windowing) = 0;
 
     virtual void SignalStoneAnnotationsChanged(const ViewerViewport& viewport,
                                                const std::string& sopInstanceUid,
@@ -2189,41 +2332,18 @@
                                                   static_cast<double>(params.GetHeight()));
         }
 
-        GetViewport().windowingPresetCenters_.resize(params.GetWindowingPresetsCount());
-        GetViewport().windowingPresetWidths_.resize(params.GetWindowingPresetsCount());
+        GetViewport().windowingPresets_.resize(params.GetWindowingPresetsCount());
 
         for (size_t i = 0; i < params.GetWindowingPresetsCount(); i++)
         {
           LOG(INFO) << "Preset windowing " << (i + 1) << "/" << params.GetWindowingPresetsCount()
-                    << ": " << params.GetWindowingPresetCenter(i)
-                    << "," << params.GetWindowingPresetWidth(i);
-
-          GetViewport().windowingPresetCenters_[i] = params.GetWindowingPresetCenter(i);
-          GetViewport().windowingPresetWidths_[i] = params.GetWindowingPresetWidth(i);
-        }
-
-        if (params.GetWindowingPresetsCount() == 0)
-        {
-          LOG(INFO) << "No preset windowing";
+                    << ": " << params.GetWindowingPreset(i).GetCenter()
+                    << "," << params.GetWindowingPreset(i).GetWidth();
+
+          GetViewport().windowingPresets_[i] = params.GetWindowingPreset(i);
         }
 
-        uint32_t bitsStored, pixelRepresentation;
-        if (dicom.ParseUnsignedInteger32(bitsStored, Orthanc::DICOM_TAG_BITS_STORED) &&
-            dicom.ParseUnsignedInteger32(pixelRepresentation, Orthanc::DICOM_TAG_PIXEL_REPRESENTATION))
-        {
-          // Added in Stone Web viewer > 2.5
-          const bool isSigned = (pixelRepresentation != 0);
-          const float maximum = powf(2.0, bitsStored);
-          GetViewport().windowingDefaultCenter_ = (isSigned ? 0.0f : maximum / 2.0f);
-          GetViewport().windowingDefaultWidth_ = maximum;
-        }
-        else
-        {
-          GetViewport().windowingDefaultCenter_ = 128;
-          GetViewport().windowingDefaultWidth_ = 256;
-        }
-
-        GetViewport().SetWindowingPreset();
+        GetViewport().SetDefaultWindowing(params);
       }
 
       uint32_t cineRate;
@@ -2257,8 +2377,7 @@
   private:
     std::string   sopInstanceUid_;
     unsigned int  frameNumber_;
-    float         windowCenter_;
-    float         windowWidth_;
+    OrthancStone::Windowing  windowing_;
     bool          isMonochrome1_;
     bool          isPrefetch_;
     
@@ -2266,15 +2385,13 @@
     SetLowQualityFrame(boost::shared_ptr<ViewerViewport> viewport,
                        const std::string& sopInstanceUid,
                        unsigned int frameNumber,
-                       float windowCenter,
-                       float windowWidth,
+                       const OrthancStone::Windowing& windowing,
                        bool isMonochrome1,
                        bool isPrefetch) :
       ICommand(viewport),
       sopInstanceUid_(sopInstanceUid),
       frameNumber_(frameNumber),
-      windowCenter_(windowCenter),
-      windowWidth_(windowWidth),
+      windowing_(windowing),
       isMonochrome1_(isMonochrome1),
       isPrefetch_(isPrefetch)
     {
@@ -2320,9 +2437,11 @@
 
           **/
 
-          const float scaling = windowWidth_ / 255.0f;
+          const float center = static_cast<float>(windowing_.GetCenter());
+          const float width = static_cast<float>(windowing_.GetWidth());
+          const float scaling = width / 255.0f;
           const float offset = (OrthancStone::LinearAlgebra::IsCloseToZero(scaling) ? 0 :
-                                (windowCenter_ - windowWidth_ / 2.0f) / scaling);
+                                (center - width / 2.0f) / scaling);
 
           Orthanc::ImageProcessing::ShiftScale(*converted, offset, scaling, false);
           break;
@@ -2408,17 +2527,6 @@
       }
       else
       {
-        if (GetViewport().windowingPresetCenters_.empty())
-        {
-          // New in Stone Web viewer 2.2: Deal with Philips multiframe
-          // (cf. mail from Tomas Kenda on 2021-08-17)
-          double windowingCenter, windowingWidth;
-          message.GetDicom().GetDefaultWindowing(windowingCenter, windowingWidth, frameNumber_);
-          GetViewport().windowingPresetCenters_.push_back(windowingCenter);
-          GetViewport().windowingPresetWidths_.push_back(windowingWidth);
-          GetViewport().SetWindowingPreset();
-        }
-
         Apply(GetViewport(), message.GetDicom(), frame.release(), sopInstanceUid_, frameNumber_);
 
         if (isPrefetch_)
@@ -2440,6 +2548,7 @@
       dicom.ExtractDicomSummary(tags, ORTHANC_STONE_MAX_TAG_LENGTH);
 
       OrthancStone::DicomInstanceParameters parameters(tags);
+      viewport.instancesCache_->Store(sopInstanceUid, parameters);
 
       std::unique_ptr<Orthanc::ImageAccessor> converted;
       
@@ -2509,15 +2618,12 @@
   boost::shared_ptr<OrthancStone::WebAssemblyViewport>   viewport_;
   boost::shared_ptr<OrthancStone::DicomResourcesLoader> loader_;
   OrthancStone::DicomSource                    source_;
-  boost::shared_ptr<FramesCache>               framesCache_;  
+  boost::shared_ptr<FramesCache>               framesCache_;
+  boost::shared_ptr<InstancesCache>            instancesCache_;
   std::unique_ptr<IFramesCollection>           frames_;
   std::unique_ptr<SeriesCursor>                cursor_;
-  float                                        windowingCenter_;
-  float                                        windowingWidth_;
-  std::vector<float>                           windowingPresetCenters_;
-  std::vector<float>                           windowingPresetWidths_;
-  float                                        windowingDefaultCenter_;
-  float                                        windowingDefaultWidth_;
+  WindowingTracker                             windowingTracker_;
+  std::vector<OrthancStone::Windowing>         windowingPresets_;
   unsigned int                                 cineRate_;
   bool                                         inverted_;
   bool                                         fitNextContent_;
@@ -2546,6 +2652,37 @@
   std::string pendingSeriesInstanceUid_;
 
 
+  void UpdateWindowing(WindowingState state,
+                       const OrthancStone::Windowing& windowing)
+  {
+    if (windowingTracker_.Update(state, windowing))
+    {
+      UpdateCurrentTextureParameters();
+
+      if (observer_.get() != NULL)
+      {
+        observer_->SignalWindowingUpdated(*this, windowingTracker_.GetWindowing());
+      }
+    }
+  }
+
+
+  void SetDefaultWindowing(const OrthancStone::DicomInstanceParameters& instance)
+  {
+    windowingTracker_.Reset();
+
+    if (instance.GetWindowingPresetsCount() == 0)
+    {
+      LOG(INFO) << "No preset windowing";
+      UpdateWindowing(WindowingState_Fallback, instance.GetFallbackWindowing());
+    }
+    else
+    {
+      UpdateWindowing(WindowingState_GlobalPreset, instance.GetWindowingPreset(0));
+    }
+  }
+
+
   void ScheduleNextPrefetch()
   {
     while (!prefetchQueue_.empty())
@@ -2668,9 +2805,41 @@
 
       case Orthanc::PixelFormat_Float32:
       {
+        {
+          // New in Stone Web viewer 2.2: Deal with Philips multiframe
+          // (cf. mail from Tomas Kenda on 2021-08-17)
+          InstancesCache::Accessor accessor(*instancesCache_, instance.GetSopInstanceUid());
+          OrthancStone::Windowing windowing;
+          if (accessor.IsValid() &&
+              accessor.GetParameters().LookupPerFrameWindowing(windowing, frameIndex))
+          {
+            UpdateWindowing(WindowingState_FramePreset, windowing);
+          }
+        }
+
         std::unique_ptr<OrthancStone::FloatTextureSceneLayer> tmp(
           new OrthancStone::FloatTextureSceneLayer(frame));
-        tmp->SetCustomWindowing(windowingCenter_, windowingWidth_);
+
+        if (windowingTracker_.GetState() == WindowingState_None ||
+            windowingTracker_.GetState() == WindowingState_Fallback)
+        {
+          const Orthanc::ImageAccessor& texture = tmp->GetTexture();
+          if (texture.GetFormat() != Orthanc::PixelFormat_Float32)
+          {
+            throw Orthanc::OrthancException(Orthanc::ErrorCode_InternalError);
+          }
+          else
+          {
+            float minValue, maxValue;
+            Orthanc::ImageProcessing::GetMinMaxFloatValue(minValue, maxValue, texture);
+
+            const float center = (minValue + maxValue) / 2.0f;
+            const float width = maxValue - minValue;
+            UpdateWindowing(WindowingState_Fallback, OrthancStone::Windowing(center, width));
+          }
+        }
+
+        tmp->SetCustomWindowing(windowingTracker_.GetWindowing().GetCenter(), windowingTracker_.GetWindowing().GetWidth());
         tmp->SetInverted(inverted_ ^ isMonochrome1);
         layer.reset(tmp.release());
         break;
@@ -3024,14 +3193,14 @@
       std::map<std::string, std::string> headers, arguments;
       // arguments["quality"] = "10";   // Low-level quality for test purpose
       arguments["window"] = (
-        boost::lexical_cast<std::string>(windowingCenter_) + ","  +
-        boost::lexical_cast<std::string>(windowingWidth_) + ",linear");
+        boost::lexical_cast<std::string>(windowingTracker_.GetWindowing().GetCenter()) + ","  +
+        boost::lexical_cast<std::string>(windowingTracker_.GetWindowing().GetWidth()) + ",linear");
 
       std::unique_ptr<OrthancStone::IOracleCommand> command(
         source_.CreateDicomWebCommand(
           uri, arguments, headers, new SetLowQualityFrame(
             GetSharedObserver(), instance.GetSopInstanceUid(), frameNumber,
-            windowingCenter_, windowingWidth_, isMonochrome1, isPrefetch)));
+            windowingTracker_.GetWindowing(), isMonochrome1, isPrefetch)));
 
       {
         std::unique_ptr<OrthancStone::ILoadersContext::ILock> lock(context_.Lock());
@@ -3051,7 +3220,7 @@
       {
         dynamic_cast<OrthancStone::FloatTextureSceneLayer&>(
           lock->GetController().GetScene().GetLayer(LAYER_TEXTURE)).
-          SetCustomWindowing(windowingCenter_, windowingWidth_);
+          SetCustomWindowing(windowingTracker_.GetWindowing().GetCenter(), windowingTracker_.GetWindowing().GetWidth());
       }
         
       lock->Invalidate();
@@ -3062,13 +3231,13 @@
                  const OrthancStone::DicomSource& source,
                  const std::string& canvas,
                  boost::shared_ptr<FramesCache> cache,
+                 boost::shared_ptr<InstancesCache> instancesCache,
                  bool softwareRendering,
                  bool linearInterpolation) :
     context_(context),
     source_(source),
     framesCache_(cache),
-    windowingDefaultCenter_(128),
-    windowingDefaultWidth_(256),
+    instancesCache_(instancesCache),
     fitNextContent_(true),
     hasFocusOnInstance_(false),
     focusFrameNumber_(0),
@@ -3105,8 +3274,6 @@
     
     emscripten_set_wheel_callback(viewport_->GetCanvasCssSelector().c_str(), this, true, OnWheel);
 
-    SetWindowingPreset();
-
     stoneAnnotations_.reset(new OrthancStone::AnnotationsSceneLayer(LAYER_ANNOTATIONS_STONE));
     stoneAnnotations_->SetProbedLayer(LAYER_TEXTURE);
   }
@@ -3115,13 +3282,7 @@
   void Handle(const OrthancStone::ViewportController::GrayscaleWindowingChanged& message)
   {
     // This event is triggered by the windowing mouse action, from class "GrayscaleWindowingSceneTracker"
-    windowingCenter_ = message.GetWindowingCenter();
-    windowingWidth_ = message.GetWindowingWidth();
-
-    if (observer_.get() != NULL)
-    {
-      observer_->SignalWindowingUpdated(*this, message.GetWindowingCenter(), message.GetWindowingWidth());
-    }
+    UpdateWindowing(WindowingState_User, message.GetWindowing());
   }
 
   
@@ -3264,12 +3425,13 @@
   static boost::shared_ptr<ViewerViewport> Create(OrthancStone::WebAssemblyLoadersContext& context,
                                                   const OrthancStone::DicomSource& source,
                                                   const std::string& canvas,
-                                                  boost::shared_ptr<FramesCache> cache,
+                                                  boost::shared_ptr<FramesCache> framesCache,
+                                                  boost::shared_ptr<InstancesCache> instancesCache,
                                                   bool softwareRendering,
                                                   bool linearInterpolation)
   {
     boost::shared_ptr<ViewerViewport> viewport(
-      new ViewerViewport(context, source, canvas, cache, softwareRendering, linearInterpolation));
+      new ViewerViewport(context, source, canvas, framesCache, instancesCache, softwareRendering, linearInterpolation));
 
     {
       std::unique_ptr<OrthancStone::ILoadersContext::ILock> lock(context.Lock());
@@ -3321,7 +3483,7 @@
 
     frames_.reset(frames);
     cursor_.reset(new SeriesCursor(frames_->GetFramesCount(), false));
-    
+
     if (frames_->GetFramesCount() != 0)
     {
       const OrthancStone::DicomInstanceParameters& firstInstance = frames_->GetInstanceOfFrame(0);
@@ -3341,11 +3503,16 @@
           cursor_.reset(new SeriesCursor(frames_->GetFramesCount(), true));
         }
       }
+
+      SetDefaultWindowing(firstInstance);
+    }
+    else
+    {
+      windowingTracker_.Reset();
     }
 
     LOG(INFO) << "Number of frames in series: " << frames_->GetFramesCount();
 
-    SetWindowingPreset();
     ClearViewport();
     prefetchQueue_.clear();
 
@@ -3615,33 +3782,6 @@
   }
 
 
-  void SetWindowingPreset()
-  {
-    assert(windowingPresetCenters_.size() == windowingPresetWidths_.size());
-
-    if (windowingPresetCenters_.empty())
-    {
-      SetWindowing(windowingDefaultCenter_, windowingDefaultWidth_);
-    }
-    else
-    {
-      SetWindowing(windowingPresetCenters_[0], windowingPresetWidths_[0]);
-    }
-  }
-
-  void SetWindowing(float windowingCenter,
-                    float windowingWidth)
-  {
-    windowingCenter_ = windowingCenter;
-    windowingWidth_ = windowingWidth;
-    UpdateCurrentTextureParameters();
-
-    if (observer_.get() != NULL)
-    {
-      observer_->SignalWindowingUpdated(*this, windowingCenter, windowingWidth);
-    }
-  }
-
   void StretchWindowing()
   {
     float minValue, maxValue;
@@ -3667,7 +3807,9 @@
       Orthanc::ImageProcessing::GetMinMaxFloatValue(minValue, maxValue, texture);
     }
 
-    SetWindowing((minValue + maxValue) / 2.0f, maxValue - minValue);
+    const float center = (minValue + maxValue) / 2.0f;
+    const float width = maxValue - minValue;
+    UpdateWindowing(WindowingState_User, OrthancStone::Windowing(center, width));
   }
 
   void FlipX()
@@ -3959,17 +4101,15 @@
 
   void FormatWindowingPresets(Json::Value& target) const
   {
-    assert(windowingPresetCenters_.size() == windowingPresetWidths_.size());
-
     target = Json::arrayValue;
 
-    for (size_t i = 0; i < windowingPresetCenters_.size(); i++)
-    {
-      const float c = windowingPresetCenters_[i];
-      const float w = windowingPresetWidths_[i];
+    for (size_t i = 0; i < windowingPresets_.size(); i++)
+    {
+      const double c = windowingPresets_[i].GetCenter();
+      const double w = windowingPresets_[i].GetWidth();
       
       std::string name = "Preset";
-      if (windowingPresetCenters_.size() > 1)
+      if (windowingPresets_.size() > 1)
       {
         name += " " + boost::lexical_cast<std::string>(i + 1);
       }
@@ -4052,6 +4192,12 @@
   }
 
 
+  void SetUserWindowing(const OrthancStone::Windowing& windowing)
+  {
+    UpdateWindowing(WindowingState_User, windowing);
+  }
+
+
   void SetPendingSeriesInstanceUid(const std::string& seriesInstanceUid)
   {
     pendingSeriesInstanceUid_ = seriesInstanceUid;
@@ -4274,8 +4420,7 @@
   }
 
   virtual void SignalWindowingUpdated(const ViewerViewport& viewport,
-                                      double windowingCenter,
-                                      double windowingWidth) ORTHANC_OVERRIDE
+                                      const OrthancStone::Windowing& windowing) ORTHANC_OVERRIDE
   {
     EM_ASM({
         const customEvent = document.createEvent("CustomEvent");
@@ -4286,8 +4431,8 @@
         window.dispatchEvent(customEvent);
       },
       viewport.GetCanvasId().c_str(),
-      static_cast<int>(boost::math::iround<double>(windowingCenter)),
-      static_cast<int>(boost::math::iround<double>(windowingWidth)));
+      static_cast<int>(boost::math::iround<double>(windowing.GetCenter())),
+      static_cast<int>(boost::math::iround<double>(windowing.GetWidth())));
 
     UpdateReferenceLines();
   }
@@ -4359,6 +4504,7 @@
 
 static OrthancStone::DicomSource source_;
 static boost::shared_ptr<FramesCache> framesCache_;
+static boost::shared_ptr<InstancesCache> instancesCache_;
 static boost::shared_ptr<OrthancStone::WebAssemblyLoadersContext> context_;
 static std::string stringBuffer_;
 static bool softwareRendering_ = false;
@@ -4409,7 +4555,7 @@
   if (found == allViewports_.end())
   {
     boost::shared_ptr<ViewerViewport> viewport(
-      ViewerViewport::Create(*context_, source_, canvas, framesCache_, softwareRendering_, linearInterpolation_));
+      ViewerViewport::Create(*context_, source_, canvas, framesCache_, instancesCache_, softwareRendering_, linearInterpolation_));
     viewport->SetMouseButtonActions(leftButtonAction_, middleButtonAction_, rightButtonAction_);
     viewport->AcquireObserver(new WebAssemblyObserver);
     viewport->SetOsiriXAnnotations(osiriXAnnotations_);
@@ -4460,6 +4606,7 @@
     context_->SetDicomCacheSize(128 * 1024 * 1024);  // 128MB
     
     framesCache_.reset(new FramesCache);
+    instancesCache_.reset(new InstancesCache);
     osiriXAnnotations_.reset(new OrthancStone::OsiriX::CollectionOfAnnotations);
 
     DISPATCH_JAVASCRIPT_EVENT("StoneInitialized");
@@ -4891,7 +5038,7 @@
   {
     try
     {
-      GetViewport(canvas)->SetWindowing(center, width);
+      GetViewport(canvas)->SetUserWindowing(OrthancStone::Windowing(center, width));
     }
     EXTERN_CATCH_EXCEPTIONS;
   }  
--- a/OrthancStone/Resources/CMake/OrthancStoneConfiguration.cmake	Fri Sep 27 22:34:17 2024 +0200
+++ b/OrthancStone/Resources/CMake/OrthancStoneConfiguration.cmake	Tue Oct 22 15:56:08 2024 +0200
@@ -451,6 +451,8 @@
   ${ORTHANC_STONE_ROOT}/Toolbox/UndoRedoStack.h
   ${ORTHANC_STONE_ROOT}/Toolbox/UnionOfRectangles.cpp
   ${ORTHANC_STONE_ROOT}/Toolbox/UnionOfRectangles.h
+  ${ORTHANC_STONE_ROOT}/Toolbox/Windowing.cpp
+  ${ORTHANC_STONE_ROOT}/Toolbox/Windowing.h
   
   ${ORTHANC_STONE_ROOT}/Viewport/DefaultViewportInteractor.cpp
   ${ORTHANC_STONE_ROOT}/Viewport/IViewport.h
--- a/OrthancStone/Sources/Loaders/SeriesFramesLoader.cpp	Fri Sep 27 22:34:17 2024 +0200
+++ b/OrthancStone/Sources/Loaders/SeriesFramesLoader.cpp	Tue Oct 22 15:56:08 2024 +0200
@@ -47,8 +47,7 @@
     std::string   sopInstanceUid_;  // Only used for debug purpose
     unsigned int  quality_;
     bool          hasWindowing_;
-    float         windowingCenter_;
-    float         windowingWidth_;
+    Windowing     windowing_;
     std::unique_ptr<Orthanc::IDynamicObject>  userPayload_;
 
   public:
@@ -62,8 +61,6 @@
       sopInstanceUid_(sopInstanceUid),
       quality_(quality),
       hasWindowing_(false),
-      windowingCenter_(0),
-      windowingWidth_(0),
       userPayload_(userPayload)
     {
     }
@@ -83,12 +80,10 @@
       return quality_;
     }
 
-    void SetWindowing(float center,
-                      float width)
+    void SetWindowing(const Windowing& windowing)
     {
       hasWindowing_ = true;
-      windowingCenter_ = center;
-      windowingWidth_ = width;
+      windowing_ = windowing;
     }
 
     bool HasWindowing() const
@@ -96,23 +91,11 @@
       return hasWindowing_;
     }
 
-    float GetWindowingCenter() const
+    const Windowing& GetWindowing() const
     {
       if (hasWindowing_)
       {
-        return windowingCenter_;
-      }
-      else
-      {
-        throw Orthanc::OrthancException(Orthanc::ErrorCode_BadSequenceOfCalls);
-      }
-    }
-
-    float GetWindowingWidth() const
-    {
-      if (hasWindowing_)
-      {
-        return windowingWidth_;
+        return windowing_;
       }
       else
       {
@@ -227,13 +210,13 @@
         Orthanc::Image scaled(parameters.GetExpectedPixelFormat(), reader.GetWidth(), reader.GetHeight(), false);
         Orthanc::ImageProcessing::Convert(scaled, reader);
           
-        float w = payload.GetWindowingWidth();
+        float w = static_cast<float>(payload.GetWindowing().GetWidth());
         if (w <= 0.01f)
         {
           w = 0.01f;  // Prevent division by zero
         }
 
-        const float c = payload.GetWindowingCenter();
+        const float c = static_cast<float>(payload.GetWindowing().GetCenter());
         const float scaling = w / 255.0f;
         const float offset = (c - w / 2.0f) / scaling;
 
@@ -417,16 +400,15 @@
       {
         const DicomInstanceParameters& parameters = frames_.GetInstanceParameters(index);
 
-        float c, w;
-        parameters.GetWindowingPresetsUnion(c, w);
+        Windowing windowing = parameters.GetWindowingPresetsUnion();
 
         std::map<std::string, std::string> arguments, headers;
-        arguments["window"] = (boost::lexical_cast<std::string>(c) + "," +
-                               boost::lexical_cast<std::string>(w) + ",linear");
+        arguments["window"] = (boost::lexical_cast<std::string>(windowing.GetCenter()) + "," +
+                               boost::lexical_cast<std::string>(windowing.GetWidth()) + ",linear");
         headers["Accept"] = "image/jpeg";
 
         std::unique_ptr<Payload> payload(new Payload(source, index, sopInstanceUid, quality, protection.release()));
-        payload->SetWindowing(c, w);
+        payload->SetWindowing(windowing);
 
         {
           std::unique_ptr<ILoadersContext::ILock> lock(context_.Lock());
--- a/OrthancStone/Sources/Scene2D/GrayscaleWindowingSceneTracker.cpp	Fri Sep 27 22:34:17 2024 +0200
+++ b/OrthancStone/Sources/Scene2D/GrayscaleWindowingSceneTracker.cpp	Tue Oct 22 15:56:08 2024 +0200
@@ -89,7 +89,7 @@
       {
         if (lock_.get() != NULL)
         {
-          lock_->GetController().BroadcastGrayscaleWindowingChanged(center, width);
+          lock_->GetController().BroadcastGrayscaleWindowingChanged(Windowing(center, width));
         }
       }        
     };
--- a/OrthancStone/Sources/Scene2DViewport/ViewportController.cpp	Fri Sep 27 22:34:17 2024 +0200
+++ b/OrthancStone/Sources/Scene2DViewport/ViewportController.cpp	Tue Oct 22 15:56:08 2024 +0200
@@ -151,10 +151,9 @@
     BroadcastMessage(SceneTransformChanged(*this));
   }
 
-  void ViewportController::BroadcastGrayscaleWindowingChanged(double windowingCenter,
-                                                              double windowingWidth)
+  void ViewportController::BroadcastGrayscaleWindowingChanged(const Windowing& windowing)
   {
-    BroadcastMessage(GrayscaleWindowingChanged(*this, windowingCenter, windowingWidth));
+    BroadcastMessage(GrayscaleWindowingChanged(*this, windowing));
   }
 
   void ViewportController::FitContent(unsigned int viewportWidth,
--- a/OrthancStone/Sources/Scene2DViewport/ViewportController.h	Fri Sep 27 22:34:17 2024 +0200
+++ b/OrthancStone/Sources/Scene2DViewport/ViewportController.h	Tue Oct 22 15:56:08 2024 +0200
@@ -27,6 +27,7 @@
 #include "../Messages/IObservable.h"
 #include "../Scene2D/Scene2D.h"
 #include "../Scene2DViewport/IFlexiblePointerTracker.h"
+#include "../Toolbox/Windowing.h"
 #include "../Viewport/IViewportInteractor.h"
 
 #include <Compatibility.h>
@@ -94,27 +95,19 @@
       ORTHANC_STONE_MESSAGE(__FILE__, __LINE__);
       
     private:
-      double  windowingCenter_;
-      double  windowingWidth_;
+      Windowing windowing_;
       
     public:
       GrayscaleWindowingChanged(const ViewportController& origin,
-                                double windowingCenter,
-                                double windowingWidth) :
+                                const Windowing& windowing) :
         OriginMessage(origin),
-        windowingCenter_(windowingCenter),
-        windowingWidth_(windowingWidth)        
+        windowing_(windowing)
       {
       }
 
-      double GetWindowingCenter() const
+      const Windowing& GetWindowing() const
       {
-        return windowingCenter_;
-      }
-
-      double GetWindowingWidth() const
-      {
-        return windowingWidth_;
+        return windowing_;
       }
     };
 
@@ -155,8 +148,7 @@
     void SetSceneToCanvasTransform(const AffineTransform2D& transform);
 
     /** Info broadcasted to the observers */
-    void BroadcastGrayscaleWindowingChanged(double windowingCenter,
-                                            double windowingWidth);
+    void BroadcastGrayscaleWindowingChanged(const Windowing& windowing);
 
     /** Forwarded to the underlying scene, and broadcasted to the observers */
     void FitContent(unsigned int viewportWidth,
--- a/OrthancStone/Sources/Toolbox/DebugDrawing2D.cpp	Fri Sep 27 22:34:17 2024 +0200
+++ b/OrthancStone/Sources/Toolbox/DebugDrawing2D.cpp	Tue Oct 22 15:56:08 2024 +0200
@@ -23,6 +23,8 @@
 
 #include "DebugDrawing2D.h"
 
+#include <stdio.h>
+
 
 namespace OrthancStone
 {
--- a/OrthancStone/Sources/Toolbox/DicomInstanceParameters.cpp	Fri Sep 27 22:34:17 2024 +0200
+++ b/OrthancStone/Sources/Toolbox/DicomInstanceParameters.cpp	Tue Oct 22 15:56:08 2024 +0200
@@ -32,6 +32,7 @@
 #include <Images/ImageProcessing.h>
 #include <Logging.h>
 #include <OrthancException.h>
+#include <SerializationToolbox.h>
 #include <Toolbox.h>
 
 
@@ -190,29 +191,29 @@
       }
     }
 
-    bool ok = false;
+
+    windowingPresets_.clear();
+
+    Vector centers, widths;
     
-    if (LinearAlgebra::ParseVector(windowingPresetCenters_, dicom, Orthanc::DICOM_TAG_WINDOW_CENTER) &&
-        LinearAlgebra::ParseVector(windowingPresetWidths_, dicom, Orthanc::DICOM_TAG_WINDOW_WIDTH))
+    if (LinearAlgebra::ParseVector(centers, dicom, Orthanc::DICOM_TAG_WINDOW_CENTER) &&
+        LinearAlgebra::ParseVector(widths, dicom, Orthanc::DICOM_TAG_WINDOW_WIDTH))
     {
-      if (windowingPresetCenters_.size() == windowingPresetWidths_.size())
+      if (centers.size() == widths.size())
       {
-        ok = true;
+        windowingPresets_.resize(centers.size());
+
+        for (size_t i = 0; i < centers.size(); i++)
+        {
+          windowingPresets_[i] = Windowing(centers[i], widths[i]);
+        }
       }
       else
       {
         LOG(ERROR) << "Mismatch in the number of preset windowing widths/centers, ignoring this";
-        ok = false;
       }
     }
 
-    if (!ok)
-    {
-      // Don't use "Vector::clear()", as it has not the same meaning as "std::vector::clear()"
-      windowingPresetCenters_.resize(0);
-      windowingPresetWidths_.resize(0);
-    }      
-
     // This computes the "IndexInSeries" metadata from Orthanc (check
     // out "Orthanc::ServerIndex::Store()")
     hasIndexInSeries_ = (
@@ -230,6 +231,65 @@
     {
       instanceNumber_ = 0;
     }
+
+
+    static const Orthanc::DicomTag DICOM_TAG_PER_FRAME_FUNCTIONAL_GROUPS_SEQUENCE(0x5200, 0x9230);
+    static const Orthanc::DicomTag DICOM_TAG_FRAME_VOI_LUT_SEQUENCE_ATTRIBUTE(0x0028, 0x9132);
+
+    const Orthanc::DicomValue* frames = dicom.TestAndGetValue(DICOM_TAG_PER_FRAME_FUNCTIONAL_GROUPS_SEQUENCE);
+    if (frames != NULL &&
+        hasNumberOfFrames_ &&
+        frames->IsSequence())
+    {
+      /**
+       * New in Stone Web viewer 2.2: Deal with Philips multiframe
+       * (cf. mail from Tomas Kenda on 2021-08-17). This cannot be done
+       * in LoadSeriesDetailsFromInstance, as the "Per Frame Functional Groups Sequence"
+       * is not available at that point.
+       **/
+
+      const Json::Value& sequence = frames->GetSequenceContent();
+
+      perFrameWindowing_.resize(numberOfFrames_);
+
+      // This corresponds to "ParsedDicomFile::GetDefaultWindowing()"
+      for (Json::ArrayIndex i = 0; i < sequence.size(); i++)
+      {
+        if (i < numberOfFrames_ &&
+            sequence[i].isMember(DICOM_TAG_FRAME_VOI_LUT_SEQUENCE_ATTRIBUTE.Format()))
+        {
+          const Json::Value& v = sequence[i][DICOM_TAG_FRAME_VOI_LUT_SEQUENCE_ATTRIBUTE.Format()];
+
+          static const char* KEY_VALUE = "Value";
+
+          if (v.isMember(KEY_VALUE) &&
+              v[KEY_VALUE].type() == Json::arrayValue &&
+              v[KEY_VALUE].size() >= 1 &&
+              v[KEY_VALUE][0].isMember(Orthanc::DICOM_TAG_WINDOW_CENTER.Format()) &&
+              v[KEY_VALUE][0].isMember(Orthanc::DICOM_TAG_WINDOW_WIDTH.Format()) &&
+              v[KEY_VALUE][0][Orthanc::DICOM_TAG_WINDOW_CENTER.Format()].isMember(KEY_VALUE) &&
+              v[KEY_VALUE][0][Orthanc::DICOM_TAG_WINDOW_WIDTH.Format()].isMember(KEY_VALUE))
+          {
+            const Json::Value& scenter = v[KEY_VALUE][0][Orthanc::DICOM_TAG_WINDOW_CENTER.Format()][KEY_VALUE];
+            const Json::Value& swidth = v[KEY_VALUE][0][Orthanc::DICOM_TAG_WINDOW_WIDTH.Format()][KEY_VALUE];
+
+            double center, width;
+            if (scenter.isString() &&
+                swidth.isString() &&
+                Orthanc::SerializationToolbox::ParseDouble(center, scenter.asString()) &&
+                Orthanc::SerializationToolbox::ParseDouble(width, swidth.asString()))
+            {
+              perFrameWindowing_[i] = Windowing(center, width);
+            }
+            else if (scenter.isNumeric() &&
+                     swidth.isNumeric())
+            {
+              perFrameWindowing_[i] = Windowing(scenter.asDouble(), swidth.asDouble());
+            }
+          }
+        }
+      }
+    }
   }
 
 
@@ -399,18 +459,45 @@
   }
 
 
+  Windowing DicomInstanceParameters::GetFallbackWindowing() const
+  {
+    double a, b;
+    if (tags_->ParseDouble(a, Orthanc::DICOM_TAG_SMALLEST_IMAGE_PIXEL_VALUE) &&
+        tags_->ParseDouble(b, Orthanc::DICOM_TAG_LARGEST_IMAGE_PIXEL_VALUE))
+    {
+      const double center = (a + b) / 2.0f;
+      const double width = (b - a);
+      return Windowing(center, width);
+    }
+
+    // Added in Stone Web viewer > 2.5
+    uint32_t bitsStored, pixelRepresentation;
+    if (tags_->ParseUnsignedInteger32(bitsStored, Orthanc::DICOM_TAG_BITS_STORED) &&
+        tags_->ParseUnsignedInteger32(pixelRepresentation, Orthanc::DICOM_TAG_PIXEL_REPRESENTATION))
+    {
+      const bool isSigned = (pixelRepresentation != 0);
+      const float maximum = powf(2.0, bitsStored);
+      return Windowing(isSigned ? 0.0f : maximum / 2.0f, maximum);
+    }
+    else
+    {
+      // Cannot infer a suitable windowing from the available tags
+      return Windowing();
+    }
+  }
+
+
   size_t DicomInstanceParameters::GetWindowingPresetsCount() const
   {
-    assert(data_.windowingPresetCenters_.size() == data_.windowingPresetWidths_.size());
-    return data_.windowingPresetCenters_.size();
+    return data_.windowingPresets_.size();
   }
   
 
-  float DicomInstanceParameters::GetWindowingPresetCenter(size_t i) const
+  Windowing DicomInstanceParameters::GetWindowingPreset(size_t i) const
   {
     if (i < GetWindowingPresetsCount())
     {
-      return static_cast<float>(data_.windowingPresetCenters_[i]);
+      return data_.windowingPresets_[i];
     }
     else
     {
@@ -418,32 +505,8 @@
     }
   }
 
-
-  float DicomInstanceParameters::GetWindowingPresetWidth(size_t i) const
-  {
-    if (i < GetWindowingPresetsCount())
-    {
-      return static_cast<float>(data_.windowingPresetWidths_[i]);
-    }
-    else
-    {
-      throw Orthanc::OrthancException(Orthanc::ErrorCode_ParameterOutOfRange);
-    }
-  }
-
-
-  static void GetWindowingBounds(float& low,
-                                 float& high,
-                                 double center,  // in
-                                 double width)   // in
-  {
-    low = static_cast<float>(center - width / 2.0);
-    high = static_cast<float>(center + width / 2.0);
-  }
-
   
-  void DicomInstanceParameters::GetWindowingPresetsUnion(float& center,
-                                                         float& width) const
+  Windowing DicomInstanceParameters::GetWindowingPresetsUnion() const
   {
     assert(tags_.get() != NULL);
     size_t s = GetWindowingPresetsCount();
@@ -452,48 +515,29 @@
     {
       // Use the largest windowing given all the preset windowings
       // that are available in the DICOM tags
-      float low, high;
-      GetWindowingBounds(low, high, GetWindowingPresetCenter(0), GetWindowingPresetWidth(0));
+      double low, high;
+      GetWindowingPreset(0).GetBounds(low, high);
 
       for (size_t i = 1; i < s; i++)
       {
-        float a, b;
-        GetWindowingBounds(a, b, GetWindowingPresetCenter(i), GetWindowingPresetWidth(i));
+        double a, b;
+        GetWindowingPreset(i).GetBounds(a, b);
         low = std::min(low, a);
         high = std::max(high, b);
       }
 
       assert(low <= high);
 
-      if (LinearAlgebra::IsNear(low, high))
+      if (!LinearAlgebra::IsNear(low, high))
       {
-        // Cannot infer a suitable windowing from the available tags
-        center = 128.0f;
-        width = 256.0f;
-      }
-      else
-      {
-        center = (low + high) / 2.0f;
-        width = (high - low);
+        const double center = (low + high) / 2.0f;
+        const double width = (high - low);
+        return Windowing(center, width);
       }
     }
-    else
-    {
-      float a, b;
-      if (tags_->ParseFloat(a, Orthanc::DICOM_TAG_SMALLEST_IMAGE_PIXEL_VALUE) &&
-          tags_->ParseFloat(b, Orthanc::DICOM_TAG_LARGEST_IMAGE_PIXEL_VALUE) &&
-          a < b)
-      {
-        center = (a + b) / 2.0f;
-        width = (b - a);
-      }
-      else
-      {
-        // Cannot infer a suitable windowing from the available tags
-        center = 128.0f;
-        width = 256.0f;
-      }
-    }
+
+    // No preset, or presets with an empty range
+    return GetFallbackWindowing();
   }
 
 
@@ -565,7 +609,8 @@
 
       if (GetWindowingPresetsCount() > 0)
       {
-        floatTexture.SetCustomWindowing(GetWindowingPresetCenter(0), GetWindowingPresetWidth(0));
+        Windowing preset = GetWindowingPreset(0);
+        floatTexture.SetCustomWindowing(preset.GetCenter(), preset.GetWidth());
       }
       
       switch (GetImageInformation().GetPhotometricInterpretation())
@@ -839,4 +884,19 @@
       return (data_.frameOffsets_[0] > data_.frameOffsets_[1]);
     }
   }
+
+
+  bool DicomInstanceParameters::LookupPerFrameWindowing(Windowing& windowing,
+                                                        unsigned int frame) const
+  {
+    if (frame < data_.perFrameWindowing_.size())
+    {
+      windowing = data_.perFrameWindowing_[frame];
+      return true;
+    }
+    else
+    {
+      return false;
+    }
+  }
 }
--- a/OrthancStone/Sources/Toolbox/DicomInstanceParameters.h	Fri Sep 27 22:34:17 2024 +0200
+++ b/OrthancStone/Sources/Toolbox/DicomInstanceParameters.h	Tue Oct 22 15:56:08 2024 +0200
@@ -26,6 +26,7 @@
 #include "../Scene2D/LookupTableTextureSceneLayer.h"
 #include "../StoneEnumerations.h"
 #include "../Toolbox/CoordinateSystem3D.h"
+#include "Windowing.h"
 
 #include <IDynamicObject.h>
 #include <DicomFormat/DicomImageInformation.h>
@@ -57,8 +58,7 @@
       bool                hasRescale_;
       double              rescaleIntercept_;
       double              rescaleSlope_;
-      Vector              windowingPresetCenters_;
-      Vector              windowingPresetWidths_;
+      std::vector<Windowing>  windowingPresets_;
       bool                hasIndexInSeries_;
       unsigned int        indexInSeries_;
       std::string         doseUnits_;
@@ -67,6 +67,7 @@
       bool                hasPixelSpacing_;
       bool                hasNumberOfFrames_;
       int32_t             instanceNumber_;
+      std::vector<Windowing>  perFrameWindowing_;
 
       explicit Data(const Orthanc::DicomMap& dicom);
     };
@@ -185,14 +186,13 @@
 
     double GetRescaleSlope() const;
 
+    Windowing GetFallbackWindowing() const;
+
     size_t GetWindowingPresetsCount() const;
 
-    float GetWindowingPresetCenter(size_t i) const;
+    Windowing GetWindowingPreset(size_t i) const;
 
-    float GetWindowingPresetWidth(size_t i) const;
-
-    void GetWindowingPresetsUnion(float& center,
-                                  float& width) const;
+    Windowing GetWindowingPresetsUnion() const;
 
     Orthanc::PixelFormat GetExpectedPixelFormat() const;
 
@@ -267,5 +267,8 @@
     CoordinateSystem3D GetMultiFrameGeometry() const;
 
     bool IsReversedFrameOffsets() const;
+
+    bool LookupPerFrameWindowing(Windowing& windowing,
+                                 unsigned int frame) const;
   };
 }
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/OrthancStone/Sources/Toolbox/Windowing.cpp	Tue Oct 22 15:56:08 2024 +0200
@@ -0,0 +1,52 @@
+/**
+ * Stone of Orthanc
+ * Copyright (C) 2012-2016 Sebastien Jodogne, Medical Physics
+ * Department, University Hospital of Liege, Belgium
+ * Copyright (C) 2017-2023 Osimis S.A., 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 Lesser 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
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public
+ * License along with this program. If not, see
+ * <http://www.gnu.org/licenses/>.
+ **/
+
+
+#include "Windowing.h"
+
+#include "LinearAlgebra.h"
+
+
+namespace OrthancStone
+{
+  Windowing::Windowing(double center,
+                       double width)
+  {
+    center_ = center;
+    width_ = std::abs(width);
+  }
+
+
+  void Windowing::GetBounds(double& low,
+                            double& high) const
+  {
+    low = center_ - width_ / 2.0;
+    high = center_ + width_ / 2.0;
+  }
+
+
+  bool Windowing::IsNear(const Windowing& other) const
+  {
+    return (LinearAlgebra::IsNear(center_, other.center_) &&
+            LinearAlgebra::IsNear(width_, other.width_));
+  }
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/OrthancStone/Sources/Toolbox/Windowing.h	Tue Oct 22 15:56:08 2024 +0200
@@ -0,0 +1,59 @@
+/**
+ * Stone of Orthanc
+ * Copyright (C) 2012-2016 Sebastien Jodogne, Medical Physics
+ * Department, University Hospital of Liege, Belgium
+ * Copyright (C) 2017-2023 Osimis S.A., 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 Lesser 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
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public
+ * License along with this program. If not, see
+ * <http://www.gnu.org/licenses/>.
+ **/
+
+
+#pragma once
+
+namespace OrthancStone
+{
+  class Windowing
+  {
+  private:
+    double  center_;
+    double  width_;
+
+  public:
+    Windowing() :
+      center_(128),
+      width_(256)
+    {
+    }
+
+    Windowing(double center,
+              double width);
+
+    double GetCenter() const
+    {
+      return center_;
+    }
+
+    double GetWidth() const
+    {
+      return width_;
+    }
+
+    void GetBounds(double& low,
+                   double& high) const;
+
+    bool IsNear(const Windowing& other) const;
+  };
+}
--- a/OrthancStone/UnitTestsSources/DicomTests.cpp	Fri Sep 27 22:34:17 2024 +0200
+++ b/OrthancStone/UnitTestsSources/DicomTests.cpp	Tue Oct 22 15:56:08 2024 +0200
@@ -66,13 +66,11 @@
   ASSERT_THROW(p->GetRescaleIntercept(), Orthanc::OrthancException);
   ASSERT_THROW(p->GetRescaleSlope(), Orthanc::OrthancException);
   ASSERT_EQ(0u, p->GetWindowingPresetsCount());
-  ASSERT_THROW(p->GetWindowingPresetCenter(0), Orthanc::OrthancException);
-  ASSERT_THROW(p->GetWindowingPresetWidth(0), Orthanc::OrthancException);
+  ASSERT_THROW(p->GetWindowingPreset(0), Orthanc::OrthancException);
 
-  float c, w;
-  p->GetWindowingPresetsUnion(c, w);
-  ASSERT_FLOAT_EQ(128.0f, c);
-  ASSERT_FLOAT_EQ(256.0f, w);
+  OrthancStone::Windowing w = p->GetWindowingPresetsUnion();
+  ASSERT_FLOAT_EQ(128.0f, w.GetCenter());
+  ASSERT_FLOAT_EQ(256.0f, w.GetWidth());
 
   ASSERT_THROW(p->GetExpectedPixelFormat(), Orthanc::OrthancException);
   ASSERT_FALSE(p->HasIndexInSeries());
@@ -96,20 +94,19 @@
 
   OrthancStone::DicomInstanceParameters p(m);
   ASSERT_EQ(3u, p.GetWindowingPresetsCount());
-  ASSERT_FLOAT_EQ(10, p.GetWindowingPresetCenter(0));
-  ASSERT_FLOAT_EQ(100, p.GetWindowingPresetCenter(1));
-  ASSERT_FLOAT_EQ(1000, p.GetWindowingPresetCenter(2));
-  ASSERT_FLOAT_EQ(50, p.GetWindowingPresetWidth(0));
-  ASSERT_FLOAT_EQ(60, p.GetWindowingPresetWidth(1));
-  ASSERT_FLOAT_EQ(70, p.GetWindowingPresetWidth(2));
+  ASSERT_FLOAT_EQ(10, p.GetWindowingPreset(0).GetCenter());
+  ASSERT_FLOAT_EQ(100, p.GetWindowingPreset(1).GetCenter());
+  ASSERT_FLOAT_EQ(1000, p.GetWindowingPreset(2).GetCenter());
+  ASSERT_FLOAT_EQ(50, p.GetWindowingPreset(0).GetWidth());
+  ASSERT_FLOAT_EQ(60, p.GetWindowingPreset(1).GetWidth());
+  ASSERT_FLOAT_EQ(70, p.GetWindowingPreset(2).GetWidth());
 
   const float a = 10.0f - 50.0f / 2.0f;
   const float b = 1000.0f + 70.0f / 2.0f;
   
-  float c, w;
-  p.GetWindowingPresetsUnion(c, w);
-  ASSERT_FLOAT_EQ((a + b) / 2.0f, c);
-  ASSERT_FLOAT_EQ(b - a, w);
+  OrthancStone::Windowing w = p.GetWindowingPresetsUnion();
+  ASSERT_FLOAT_EQ((a + b) / 2.0f, w.GetCenter());
+  ASSERT_FLOAT_EQ(b - a, w.GetWidth());
 }
 
 
--- a/TODO	Fri Sep 27 22:34:17 2024 +0200
+++ b/TODO	Tue Oct 22 15:56:08 2024 +0200
@@ -45,9 +45,6 @@
 * Order the studies in the left column according to their Instance
   Number (0020,0013). Suggestion by Joseph Maratt.
 
-* Add a button to download PDF:
-  https://discourse.orthanc-server.org/t/printing-pdf-reports-in-stone-of-orthanc/4731
-
 * Open using Orthanc parent/study/series identifier, or using DICOM
   accession number:
   https://discourse.orthanc-server.org/t/stone-web-viewer-what-is-the-link-to-use-through-the-access-number/4808