changeset 1703:76c590a62755

start work on series with multiple multiframe instances
author Sebastien Jodogne <s.jodogne@gmail.com>
date Fri, 27 Nov 2020 16:36:43 +0100
parents bc40b6450261
children 902d13889ae4
files Applications/StoneWebViewer/WebApplication/app.js Applications/StoneWebViewer/WebApplication/index.html Applications/StoneWebViewer/WebAssembly/StoneWebViewer.cpp OrthancStone/Sources/Loaders/SeriesThumbnailsLoader.h
diffstat 4 files changed, 251 insertions(+), 68 deletions(-) [+]
line wrap: on
line diff
--- a/Applications/StoneWebViewer/WebApplication/app.js	Fri Nov 27 13:57:28 2020 +0100
+++ b/Applications/StoneWebViewer/WebApplication/app.js	Fri Nov 27 16:36:43 2020 +0100
@@ -224,10 +224,7 @@
     });
   },
   methods: {
-    SeriesDragAccept: function(event) {
-      event.preventDefault();
-    },
-    SeriesDragDrop: function(event) {
+    DragDrop: function(event) {
       event.preventDefault();
 
       // The "parseInt()" is because of Microsoft Edge Legacy (*)
@@ -368,7 +365,8 @@
       selectedStudies: [],
       series: [],
       studies: [],
-      seriesIndex: {}  // Maps "SeriesInstanceUID" to "index in this.series"
+      seriesIndex: {},  // Maps "SeriesInstanceUID" to "index in this.series"
+      multiframeInstanceThumbnails: {}
     }
   },
   computed: {
@@ -502,7 +500,8 @@
               'complete' : false,
               'type' : stone.ThumbnailType.LOADING,
               'color': study.color,
-              'tags': sourceSeries[i]
+              'tags': sourceSeries[i],
+              'multiframeInstances': null
             });
           }
         }
@@ -665,6 +664,10 @@
             stone.FetchPdf(studyInstanceUid, seriesInstanceUid);
             delete pendingSeriesPdf_[seriesInstanceUid];
           }
+
+          if (stone.LoadMultiframeInstancesFromSeries(seriesInstanceUid)) {
+            series.multiframeInstances = JSON.parse(stone.GetStringBuffer());
+          }
         }
 
         // https://fr.vuejs.org/2016/02/06/common-gotchas/#Why-isn%E2%80%99t-the-DOM-updating
@@ -828,6 +831,24 @@
     }
     
     this.modalNotDiagnostic = this.settingNotDiagnostic;
+
+    var that = this;
+    
+    window.addEventListener('MultiframeInstanceThumbnailLoaded', function(args) {
+      that.$set(that.multiframeInstanceThumbnails, args.detail.sopInstanceUid, args.detail.thumbnail);
+    });
+
+    window.addEventListener('ThumbnailLoaded', function(args) {
+      //var studyInstanceUid = args.detail.studyInstanceUid;
+      var seriesInstanceUid = args.detail.seriesInstanceUid;
+      that.UpdateSeriesThumbnail(seriesInstanceUid);
+    });
+
+    window.addEventListener('MetadataLoaded', function(args) {
+      var studyInstanceUid = args.detail.studyInstanceUid;
+      var seriesInstanceUid = args.detail.seriesInstanceUid;
+      that.UpdateIsSeriesComplete(studyInstanceUid, seriesInstanceUid);
+    });
   }
 });
 
@@ -916,20 +937,6 @@
 });
 
 
-window.addEventListener('ThumbnailLoaded', function(args) {
-  //var studyInstanceUid = args.detail.studyInstanceUid;
-  var seriesInstanceUid = args.detail.seriesInstanceUid;
-  app.UpdateSeriesThumbnail(seriesInstanceUid);
-});
-
-
-window.addEventListener('MetadataLoaded', function(args) {
-  var studyInstanceUid = args.detail.studyInstanceUid;
-  var seriesInstanceUid = args.detail.seriesInstanceUid;
-  app.UpdateIsSeriesComplete(studyInstanceUid, seriesInstanceUid);
-});
-
-
 window.addEventListener('StoneException', function() {
   console.error('Exception catched in Stone');
 });
--- a/Applications/StoneWebViewer/WebApplication/index.html	Fri Nov 27 13:57:28 2020 +0100
+++ b/Applications/StoneWebViewer/WebApplication/index.html	Fri Nov 27 16:36:43 2020 +0100
@@ -187,57 +187,93 @@
 
                     <div class="wvStudyIsland__main">
                       <ul class="wvSerieslist">
-                        <li class="wvSerieslist__seriesItem"
-                            v-bind:class="{ highlighted : GetActiveSeries().includes(series[seriesIndex].tags['0020,000e']), 'wvSerieslist__seriesItem--list' : leftMode != 'grid', 'wvSerieslist__seriesItem--grid' : leftMode == 'grid' }"
-                            v-on:dragstart="SeriesDragStart($event, seriesIndex)"
-                            v-on:click="ClickSeries(seriesIndex)"
-                            v-for="seriesIndex in study.series">
-                          <div class="wvSerieslist__picture" style="z-index:0"
-                               draggable="true"
-                               v-if="series[seriesIndex].type != stone.ThumbnailType.UNKNOWN"
-                               >
-                            <div v-if="series[seriesIndex].type == stone.ThumbnailType.LOADING">
-                              <img src="img/loading.gif"
-                                   style="vertical-align:baseline"
-                                   width="65px" height="65px"
-                                   />
+
+                        <!-- Series without multiple multiframe instances -->
+                        <span v-for="seriesIndex in study.series">
+                          <li class="wvSerieslist__seriesItem"
+                              v-bind:class="{ highlighted : GetActiveSeries().includes(series[seriesIndex].tags['0020,000e']), 'wvSerieslist__seriesItem--list' : leftMode != 'grid', 'wvSerieslist__seriesItem--grid' : leftMode == 'grid' }"
+                              v-on:dragstart="SeriesDragStart($event, seriesIndex)"
+                              v-on:click="ClickSeries(seriesIndex)"
+                              v-if="series[seriesIndex].multiframeInstances === null">
+                            <div class="wvSerieslist__picture" style="z-index:0"
+                                 draggable="true"
+                                 v-if="series[seriesIndex].type != stone.ThumbnailType.UNKNOWN"
+                                 >
+                              <div v-if="series[seriesIndex].type == stone.ThumbnailType.LOADING">
+                                <img src="img/loading.gif"
+                                     style="vertical-align:baseline"
+                                     width="65px" height="65px"
+                                     />
+                              </div>
+
+                              <i v-if="series[seriesIndex].type == stone.ThumbnailType.PDF"
+                                 class="wvSerieslist__placeholderIcon fa fa-file-pdf"></i>
+
+                              <i v-if="series[seriesIndex].type == stone.ThumbnailType.VIDEO"
+                                 class="wvSerieslist__placeholderIcon fa fa-video-video"></i>
+                              
+                              <div v-if="[stone.ThumbnailType.IMAGE, stone.ThumbnailType.NO_PREVIEW].includes(series[seriesIndex].type)"
+                                   class="wvSerieslist__placeholderIcon"
+                                   v-bind:title="leftMode == 'full' ? null : '[' + series[seriesIndex].tags['0008,0060'] + '] ' + series[seriesIndex].tags['0008,103e']">
+                                <i v-if="series[seriesIndex].type == stone.ThumbnailType.NO_PREVIEW"
+                                   class="fa fa-eye-slash"></i>
+
+                                <img v-if="series[seriesIndex].type == stone.ThumbnailType.IMAGE"
+                                     v-bind:src="series[seriesIndex].thumbnail"
+                                     style="vertical-align:baseline"
+                                     width="65px" height="65px"
+                                     v-bind:title="leftMode == 'full' ? null : '[' + series[seriesIndex].tags['0008,0060'] + '] ' + series[seriesIndex].tags['0008,103e']"
+                                     />
+                                
+                                <div v-bind:class="'wvSerieslist__badge--' + study.color"
+                                     v-if="series[seriesIndex].numberOfFrames != 0">{{ series[seriesIndex].numberOfFrames }}</div>
+                              </div>
                             </div>
 
-                            <i v-if="series[seriesIndex].type == stone.ThumbnailType.PDF"
-                               class="wvSerieslist__placeholderIcon fa fa-file-pdf"></i>
-
-                            <i v-if="series[seriesIndex].type == stone.ThumbnailType.VIDEO"
-                               class="wvSerieslist__placeholderIcon fa fa-video-video"></i>
+                            <div v-if="leftMode == 'full'" class="wvSerieslist__information"
+                                 draggable="true"
+                                 v-on:dragstart="SeriesDragStart($event, seriesIndex)"
+                                 v-on:click="ClickSeries(seriesIndex)">
+                              <p class="wvSerieslist__label">
+                                [{{ series[seriesIndex].tags['0008,0060'] }}]
+                                {{ series[seriesIndex].tags['0008,103e'] }}
+                              </p>
+                            </div>
+                          </li>
 
-                            
-                            <div v-if="[stone.ThumbnailType.IMAGE, stone.ThumbnailType.NO_PREVIEW].includes(series[seriesIndex].type)"
-                                 class="wvSerieslist__placeholderIcon"
-                                 v-bind:title="leftMode == 'full' ? null : '[' + series[seriesIndex].tags['0008,0060'] + '] ' + series[seriesIndex].tags['0008,103e']">
-                              <i v-if="series[seriesIndex].type == stone.ThumbnailType.NO_PREVIEW"
-                                 class="fa fa-eye-slash"></i>
 
+                          <!-- Series with multiple multiframe instances (CINE) -->
+                          <li class="wvSerieslist__seriesItem"
+                              v-bind:class="{ highlighted : GetActiveSeries().includes(series[seriesIndex].tags['0020,000e']), 'wvSerieslist__seriesItem--list' : leftMode != 'grid', 'wvSerieslist__seriesItem--grid' : leftMode == 'grid' }"
+                              v-on:dragstart="SeriesDragStart($event, seriesIndex)"
+                              v-on:click="ClickSeries(seriesIndex)"
+                              v-for="(numberOfFrames, sopInstanceUid) in series[seriesIndex].multiframeInstances">
+                            <div class="wvSerieslist__picture" style="z-index:0"
+                                 draggable="true">
                               <img v-if="series[seriesIndex].type == stone.ThumbnailType.IMAGE"
-                                   v-bind:src="series[seriesIndex].thumbnail"
+                                   v-bind:src="sopInstanceUid in multiframeInstanceThumbnails ? multiframeInstanceThumbnails[sopInstanceUid] : series[seriesIndex].thumbnail"
                                    style="vertical-align:baseline"
                                    width="65px" height="65px"
                                    v-bind:title="leftMode == 'full' ? null : '[' + series[seriesIndex].tags['0008,0060'] + '] ' + series[seriesIndex].tags['0008,103e']"
                                    />
                               
-                              <div v-bind:class="'wvSerieslist__badge--' + study.color"
-                                   v-if="series[seriesIndex].numberOfFrames != 0">{{ series[seriesIndex].numberOfFrames }}</div>
+                              <div v-bind:class="'wvSerieslist__badge--' + study.color">
+                                {{ numberOfFrames }}
+                              </div>
                             </div>
-                          </div>
 
-                          <div v-if="leftMode == 'full'" class="wvSerieslist__information"
-                               draggable="true"
-                               v-on:dragstart="SeriesDragStart($event, seriesIndex)"
-                               v-on:click="ClickSeries(seriesIndex)">
-                            <p class="wvSerieslist__label">
-                              [{{ series[seriesIndex].tags['0008,0060'] }}]
-                              {{ series[seriesIndex].tags['0008,103e'] }}
-                            </p>
-                          </div>
-                        </li>
+                            <div v-if="leftMode == 'full'" class="wvSerieslist__information"
+                                 draggable="true"
+                                 v-on:dragstart="SeriesDragStart($event, seriesIndex)"
+                                 v-on:click="ClickSeries(seriesIndex)">
+                              <p class="wvSerieslist__label">
+                                [{{ series[seriesIndex].tags['0008,0060'] }}]
+                                {{ series[seriesIndex].tags['0008,103e'] }}
+                              </p>
+                            </div>
+                          </li>
+                          
+                        </span>
                       </ul>
                     </div>
                   </div>
@@ -550,8 +586,8 @@
                            'wvSplitpane__cellBorder--yellow' : series.color == 'yellow', 
                            'wvSplitpane__cellBorder--violet' : series.color == 'violet'
                            }" 
-             v-on:dragover="SeriesDragAccept($event)"
-             v-on:drop="SeriesDragDrop($event)"
+             ondragover="event.preventDefault()"
+             v-on:drop="DragDrop($event)"
              style="width:100%;height:100%">
           <div class="wvSplitpane__cell" 
                v-on:click="MakeActive()">
--- a/Applications/StoneWebViewer/WebAssembly/StoneWebViewer.cpp	Fri Nov 27 13:57:28 2020 +0100
+++ b/Applications/StoneWebViewer/WebAssembly/StoneWebViewer.cpp	Fri Nov 27 16:36:43 2020 +0100
@@ -195,6 +195,9 @@
     virtual void SignalSeriesPdfLoaded(const std::string& studyInstanceUid,
                                        const std::string& seriesInstanceUid,
                                        const std::string& pdf) = 0;
+
+    virtual void SignalMultiframeInstanceThumbnailLoaded(const std::string& sopInstanceUid,
+                                                         const std::string& jpeg) = 0;
   };
   
 private:
@@ -207,6 +210,7 @@
   boost::shared_ptr<OrthancStone::DicomResourcesLoader>    resourcesLoader_;
   boost::shared_ptr<OrthancStone::SeriesThumbnailsLoader>  thumbnailsLoader_;
   boost::shared_ptr<OrthancStone::SeriesMetadataLoader>    metadataLoader_;
+  std::set<std::string>                                    scheduledMultiframeInstances_;
 
   explicit ResourcesLoader(OrthancStone::ILoadersContext& context,
                            const OrthancStone::DicomSource& source) :
@@ -361,6 +365,44 @@
     }
   }
 
+  void FetchInstanceThumbnail(const std::string& studyInstanceUid,
+                              const std::string& seriesInstanceUid,
+                              const std::string& sopInstanceUid)
+  {
+    if (scheduledMultiframeInstances_.find(sopInstanceUid) == scheduledMultiframeInstances_.end())
+    {
+      scheduledMultiframeInstances_.insert(sopInstanceUid);
+      
+      std::map<std::string, std::string> arguments;
+      std::map<std::string, std::string> headers;
+      arguments["viewport"] = (
+        boost::lexical_cast<std::string>(thumbnailsLoader_->GetThumbnailWidth()) + "," +
+        boost::lexical_cast<std::string>(thumbnailsLoader_->GetThumbnailHeight()));
+      headers["Accept"] = Orthanc::MIME_JPEG;
+
+      const std::string uri = ("studies/" + studyInstanceUid + "/series/" + seriesInstanceUid +
+                               "/instances/" + sopInstanceUid + "/frames/1/rendered");
+
+      {
+        std::unique_ptr<OrthancStone::ILoadersContext::ILock> lock(context_.Lock());
+        lock->Schedule(
+          GetSharedObserver(), PRIORITY_LOW + 2, source_.CreateDicomWebCommand(
+            uri, arguments, headers, new Orthanc::SingleValueObject<std::string>(sopInstanceUid)));
+      }
+    }
+  }
+
+  void HandleInstanceThumbnail(const OrthancStone::HttpCommand::SuccessMessage& message)
+  {
+    if (observer_.get() != NULL)
+    {
+      const std::string& sopInstanceUid =
+        dynamic_cast<const Orthanc::SingleValueObject<std::string>&>(
+          message.GetOrigin().GetPayload()).GetValue();
+      observer_->SignalMultiframeInstanceThumbnailLoaded(sopInstanceUid, message.GetAnswer());
+    }
+  }
+
 public:
   static boost::shared_ptr<ResourcesLoader> Create(OrthancStone::ILoadersContext::ILock& lock,
                                                    const OrthancStone::DicomSource& source)
@@ -382,7 +424,10 @@
 
     loader->Register<OrthancStone::ParseDicomSuccessMessage>(
       lock.GetOracleObservable(), &ResourcesLoader::Handle);
-    
+
+    loader->Register<OrthancStone::HttpCommand::SuccessMessage>(
+      lock.GetOracleObservable(), &ResourcesLoader::HandleInstanceThumbnail);
+
     return loader;
   }
   
@@ -428,13 +473,13 @@
   }
 
   void GetStudy(Orthanc::DicomMap& target,
-                size_t i)
+                size_t i) const
   {
     target.Assign(studies_->GetResource(i));
   }
 
   void GetSeries(Orthanc::DicomMap& target,
-                 size_t i)
+                 size_t i) const
   {
     target.Assign(series_->GetResource(i));
 
@@ -449,26 +494,65 @@
 
   OrthancStone::SeriesThumbnailType GetSeriesThumbnail(std::string& image,
                                                        std::string& mime,
-                                                       const std::string& seriesInstanceUid)
+                                                       const std::string& seriesInstanceUid) const
   {
     return thumbnailsLoader_->GetSeriesThumbnail(image, mime, seriesInstanceUid);
   }
 
   void FetchSeriesMetadata(int priority,
                            const std::string& studyInstanceUid,
-                           const std::string& seriesInstanceUid)
+                           const std::string& seriesInstanceUid) const
   {
     metadataLoader_->ScheduleLoadSeries(priority, source_, studyInstanceUid, seriesInstanceUid);
   }
 
-  bool IsSeriesComplete(const std::string& seriesInstanceUid)
+  bool IsSeriesComplete(const std::string& seriesInstanceUid) const
   {
     OrthancStone::SeriesMetadataLoader::Accessor accessor(*metadataLoader_, seriesInstanceUid);
     return accessor.IsComplete();
   }
 
+  bool LookupMultiframeSeries(std::map<std::string, unsigned int>& numberOfFramesPerInstance,
+                              const std::string& seriesInstanceUid)
+  {
+    numberOfFramesPerInstance.clear();
+
+    OrthancStone::SeriesMetadataLoader::Accessor accessor(*metadataLoader_, seriesInstanceUid);
+    if (accessor.IsComplete() &&
+        accessor.GetInstancesCount() >= 2)
+    {
+      bool isMultiframe = false;
+      
+      for (size_t i = 0; i < accessor.GetInstancesCount(); i++)
+      {
+        OrthancStone::DicomInstanceParameters p(accessor.GetInstance(i));
+        numberOfFramesPerInstance[p.GetSopInstanceUid()] = p.GetNumberOfFrames();
+
+        if (p.GetNumberOfFrames() > 1)
+        {
+          isMultiframe = true;
+        }
+      }
+
+      if (isMultiframe)
+      {
+        for (size_t i = 0; i < accessor.GetInstancesCount(); i++)
+        {
+          OrthancStone::DicomInstanceParameters p(accessor.GetInstance(i));
+          FetchInstanceThumbnail(p.GetStudyInstanceUid(), p.GetSeriesInstanceUid(), p.GetSopInstanceUid());
+        }
+      }
+
+      return isMultiframe;
+    }
+    else
+    {
+      return false;
+    }
+  }
+
   bool SortSeriesFrames(OrthancStone::SortedFrames& target,
-                        const std::string& seriesInstanceUid)
+                        const std::string& seriesInstanceUid) const
   {
     OrthancStone::SeriesMetadataLoader::Accessor accessor(*metadataLoader_, seriesInstanceUid);
     
@@ -2674,6 +2758,24 @@
       pdf.empty() ? 0 : reinterpret_cast<intptr_t>(pdf.c_str()),  // Explicit conversion to an integer
       pdf.size());
   }
+
+
+  virtual void SignalMultiframeInstanceThumbnailLoaded(const std::string& sopInstanceUid,
+                                                       const std::string& jpeg) ORTHANC_OVERRIDE
+  {
+    std::string dataUriScheme;
+    Orthanc::Toolbox::EncodeDataUriScheme(dataUriScheme, "image/jpeg", jpeg);    
+    
+    EM_ASM({
+        const customEvent = document.createEvent("CustomEvent");
+        customEvent.initCustomEvent("MultiframeInstanceThumbnailLoaded", false, false,
+                                    { "sopInstanceUid" : UTF8ToString($0),
+                                        "thumbnail" : UTF8ToString($1) });
+        window.dispatchEvent(customEvent);
+      },
+      sopInstanceUid.c_str(),
+      dataUriScheme.c_str());
+  }
 };
 
 
@@ -3246,4 +3348,32 @@
     }
     EXTERN_CATCH_EXCEPTIONS;
   }
+
+
+  EMSCRIPTEN_KEEPALIVE
+  int LoadMultiframeInstancesFromSeries(const char* seriesInstanceUid)
+  {
+    try
+    {
+      std::map<std::string, unsigned int> numberOfFramesPerInstance;
+      if (GetResourcesLoader().LookupMultiframeSeries(numberOfFramesPerInstance, seriesInstanceUid))
+      {
+        Json::Value json = Json::objectValue;
+        for (std::map<std::string, unsigned int>::const_iterator it =
+               numberOfFramesPerInstance.begin(); it != numberOfFramesPerInstance.end(); ++it)
+        {
+          json[it->first] = it->second;
+        }
+
+        stringBuffer_ = json.toStyledString();
+        return true;
+      }
+      else
+      {
+        return false;
+      }
+    }
+    EXTERN_CATCH_EXCEPTIONS;
+    return false;
+  }
 }
--- a/OrthancStone/Sources/Loaders/SeriesThumbnailsLoader.h	Fri Nov 27 13:57:28 2020 +0100
+++ b/OrthancStone/Sources/Loaders/SeriesThumbnailsLoader.h	Fri Nov 27 16:36:43 2020 +0100
@@ -213,6 +213,16 @@
                           unsigned int height);
     
     void Clear();
+
+    unsigned int GetThumbnailWidth() const
+    {
+      return width_;      
+    }
+
+    unsigned int GetThumbnailHeight() const
+    {
+      return height_;      
+    }
     
     SeriesThumbnailType GetSeriesThumbnail(std::string& image,
                                            std::string& mime,