changeset 2026:04148de691a7 deep-learning

integration mainline->deep-learning
author Sebastien Jodogne <s.jodogne@gmail.com>
date Mon, 05 Dec 2022 08:29:49 +0100
parents 37d6805b80ee (current diff) 8ff083f67628 (diff)
children 84ad648b86ac
files Applications/StoneWebViewer/WebApplication/app.js Applications/StoneWebViewer/WebApplication/index.html Applications/StoneWebViewer/WebAssembly/StoneWebViewer.cpp
diffstat 9 files changed, 491 insertions(+), 134 deletions(-) [+]
line wrap: on
line diff
--- a/Applications/StoneWebViewer/NEWS	Fri Nov 18 00:37:00 2022 +0100
+++ b/Applications/StoneWebViewer/NEWS	Mon Dec 05 08:29:49 2022 +0100
@@ -2,6 +2,24 @@
 ===============================
 
 
+Version 2.5 (2022-12-05)
+========================
+
+* Click-drag is available on the vertical slider
+* Added key bindings:
+  - Left/right arrows to change the active frame
+  - Up/down arrows to change the active series
+  - Page up/down to change the active study
+  - Space bar to play/pause videos
+* New URL argument "menu" to change the layout of the list of studies/series
+* The first series to be loaded is now automatically opened
+* Annotations are grouped into one submenu for narrow screens
+* Support generation of ZIP archives in the presence of authorization tokens
+* Fix measurement of arcs
+* Width of the vertical slider has doubled to ease user interactions
+* Patient sex is displayed in the top-left information panel
+
+
 Version 2.4 (2022-11-02)
 ========================
 
--- a/Applications/StoneWebViewer/NOTES.txt	Fri Nov 18 00:37:00 2022 +0100
+++ b/Applications/StoneWebViewer/NOTES.txt	Mon Dec 05 08:29:49 2022 +0100
@@ -12,14 +12,8 @@
   in the background.
 
 
-- Contrarily to the Osimis Web viewer, the Stone Web viewer does not
-  currently support annotations, and will not support Live Share.
-
-
-- The Stone Web viewer has no "timeline" bar to see the position of
-  the current frame in the series. However, pressing the "Ctrl" key
-  together with mouse wheel enables fast move, i.e. this changes the
-  current frame by skipping 1/20th of the frames in the series.
+- The Stone Web viewer does not support Live Share that was available
+  in old versions of the Osimis Web viewer.
 
 
 - The Stone Web viewer displays a color block at the bottom-right of
@@ -130,6 +124,16 @@
 "Authorization: Bearer Hello"
 
 
+Additional options
+==================
+
+- If present in the URL, the "menu" argument can be used to set the
+  default layout of the left-hand list of studies/series. Its allowed
+  values are "hidden", "small", "grid" (default value at the study
+  level), or "full" (default value at the series level). (new in Stone
+  Web viewer 2.5)
+
+
 Dynamic actions using messages
 ==============================
 
--- a/Applications/StoneWebViewer/WebApplication/app-fixes.css	Fri Nov 18 00:37:00 2022 +0100
+++ b/Applications/StoneWebViewer/WebApplication/app-fixes.css	Mon Dec 05 08:29:49 2022 +0100
@@ -50,12 +50,12 @@
     top: 0;
     bottom: 0;
     right: 0;
-    width: 10px;
+    width: 20px;
     background-color: #1b663e;
 }
 
 .wvInfoRightMargin {
-    right: 10px !important;  /* must match the "width" of "wvVerticalScrollbar" */
+    right: 20px !important;  /* must match the "width" of "wvVerticalScrollbar" */
 }
 
 .wvVerticalScrollbarHighlight {
--- a/Applications/StoneWebViewer/WebApplication/app.js	Fri Nov 18 00:37:00 2022 +0100
+++ b/Applications/StoneWebViewer/WebApplication/app.js	Mon Dec 05 08:29:49 2022 +0100
@@ -34,6 +34,7 @@
 var SERIES_DESCRIPTION = '0008,103e';
 var MODALITY = '0008,0060';
 var PATIENT_BIRTH_DATE = '0010,0030';
+var PATIENT_SEX = '0010,0040';
 
 // Registry of the PDF series for which the instance metadata is still waiting
 var pendingSeriesPdf_ = {};
@@ -52,6 +53,9 @@
 var MOUSE_TOOL_CREATE_TEXT_ANNOTATION = 12;  // New in 2.4
 var MOUSE_TOOL_MAGNIFYING_GLASS = 13;        // New in 2.4
 
+var hasAuthorizationToken = false;
+var axiosHeaders = {};
+
 
 function getParameterFromUrl(key) {
   var url = window.location.search.substring(1);
@@ -124,6 +128,17 @@
 }
 
 
+function LookupIndexOfResource(array, tag, value) {
+  for (var i = 0; i < array.length; i++) {
+    if (array[i].tags[tag] == value) {
+      return i;
+    }
+  }
+  
+  return -1;
+}
+
+
 /**
  * Enable support for tooltips in Bootstrap. This function must be
  * called after each modification to the DOM that introduces new
@@ -139,6 +154,63 @@
 }
 
 
+function TriggerDownloadFromUri(uri, filename, mime)
+{
+  if (hasAuthorizationToken) {
+    axios.get(uri, {
+      headers: axiosHeaders,
+      responseType: 'arraybuffer'
+    })
+      .then(function(response) {
+        const blob = new Blob([ response.data ], { type: mime });
+        const url = URL.createObjectURL(blob);
+
+        //window.open(url, '_blank');
+
+        // https://stackoverflow.com/a/19328891
+        var a = document.createElement("a");
+        document.body.appendChild(a);
+        a.style = "display: none";
+        a.href = url;
+        a.download = filename;
+        a.click();
+        window.URL.revokeObjectURL(url);
+      });
+
+  } else {
+    // This version was used in Stone Web viewer <= 2.4, but doesn't
+    // work with authorization headers
+    
+    /**
+     * The use of "window.open()" below might be blocked (depending on
+     * the browser criteria to block popup).  As a consequence, we
+     * prefer to set "window.location".
+     * https://www.nngroup.com/articles/the-top-ten-web-design-mistakes-of-1999/
+     **/
+    // window.open(uri, '_blank');
+    window.location.href = uri;
+  }
+}
+
+
+/**
+ * The "mousemove" and "mouseup" events were added in Stone Web viewer
+ * 2.5 to allow click/drag on the vertical scrollbar.
+ **/
+var activeVerticalScrollbarViewport = null;
+var activeVerticalScrollbarTarget = null;
+
+window.addEventListener('mousemove', function(event) {
+  if (activeVerticalScrollbarViewport !== null) {
+    activeVerticalScrollbarViewport.ClickVerticalScrollbar(event, activeVerticalScrollbarTarget);
+    event.preventDefault();
+  }
+});
+
+window.addEventListener('mouseup', function(event) {
+  activeVerticalScrollbarViewport = null;
+});
+
 
 Vue.component('viewport', {
   props: [ 'left', 'top', 'width', 'height', 'canvasId', 'active', 'content', 'viewportIndex',
@@ -250,19 +322,33 @@
         this.videoUri = '';
         if (this.globalConfiguration.OrthancApiRoot) {
           var that = this;
-          axios.post(that.globalConfiguration.OrthancApiRoot + '/tools/find',
-                     {
-                       Level : 'Instance',
-                       Query : {
-                         StudyInstanceUID: studyInstanceUid
-                       }
-                     })
+          axios.post(that.globalConfiguration.OrthancApiRoot + '/tools/find', {
+            Level : 'Instance',
+            Query : {
+              StudyInstanceUID: studyInstanceUid
+            }
+          }, {
+            headers: axiosHeaders
+          })
             .then(function(response) {
               if (response.data.length != 1) {
                 throw('');
               }
               else {
-                that.videoUri = that.globalConfiguration.OrthancApiRoot + '/instances/' + response.data[0] + '/frames/0/raw';
+                var uri = that.globalConfiguration.OrthancApiRoot + '/instances/' + response.data[0] + '/frames/0/raw';
+
+                if (hasAuthorizationToken) {
+                  axios.get(uri, {
+                    headers: axiosHeaders,
+                    responseType: 'arraybuffer'
+                  })
+                    .then(function(response) {
+                      const blob = new Blob([ response.data ]);
+                      that.videoUri = URL.createObjectURL(blob);
+                    });
+                } else {
+                  that.videoUri = uri;
+                }
               }
             })
             .catch(function(error) {
@@ -331,6 +417,12 @@
         that.windowingWidth = args.detail.windowingWidth;
       }
     });
+
+    window.addEventListener('KeyCineSwitch', function(args) {
+      if (that.active) {
+        that.KeyCineSwitch();
+      }
+    });
   },
   methods: {
     DragDrop: function(event) {
@@ -353,7 +445,7 @@
     },
     CinePlay: function() {
       this.cineControls = true;
-      this.cineIncrement = 1;
+      this.cineIncrement = -1;
       this.UpdateCine();
     },
     CinePause: function() {
@@ -367,9 +459,16 @@
     },
     CineBackward: function() {
       this.cineControls = true;
-      this.cineIncrement = -1;
+      this.cineIncrement = 1;
       this.UpdateCine();
     },
+    KeyCineSwitch: function() {
+      if (this.cineIncrement != 0) {
+        this.CinePause();
+      } else {
+        this.CinePlay();
+      }
+    },
     UpdateCine: function() {
       // Cancel the previous cine loop, if any
       if (this.cineTimeoutId !== null) {
@@ -411,6 +510,23 @@
       if (reschedule) {
         this.cineTimeoutId = setTimeout(this.CineCallback, 1000.0 / this.cineFramesPerSecond);
       }     
+    },
+    ClickVerticalScrollbar: function(event, target) {
+      if (target == undefined) {
+        target = event.currentTarget;
+        activeVerticalScrollbarViewport = this;
+        activeVerticalScrollbarTarget = target;
+      }
+      
+      var offset = target.getClientRects()[0];
+      var y = event.clientY - offset.top;
+      var height = target.offsetHeight;
+      var frame = Math.min(this.numberOfFrames - 1, Math.floor(y * this.numberOfFrames / (height - 1)));
+      
+      if (frame >= 0 &&
+          frame < this.numberOfFrames) {
+        this.currentFrame = frame;
+      }
     }
   }
 });
@@ -426,6 +542,7 @@
       leftVisible: true,
       viewportLayoutButtonsVisible: false,
       mouseActionsVisible: false,
+      annotationActionsVisible: false,
       activeViewport: 0,
       showInfo: true,
       showReferenceLines: true,
@@ -437,6 +554,7 @@
       orthancSystem: {},  // Only available if "OrthancApiRoot" configuration option is set
       stoneWebViewerVersion: '...',
       emscriptenVersion: '...',
+      isFirstSeries: true,
 
       modalWarning: false,
       modalNotDiagnostic: false,
@@ -541,8 +659,26 @@
         });
       }
     },
+
+    GetActiveViewportSeriesTags: function() {
+      if (this.activeViewport == 1) {
+        return this.viewport1Content.series.tags;
+      }
+      else if (this.activeViewport == 2) {
+        return this.viewport2Content.series.tags;
+      }
+      else if (this.activeViewport == 3) {
+        return this.viewport3Content.series.tags;
+      }
+      else if (this.activeViewport == 4) {
+        return this.viewport4Content.series.tags;
+      }
+      else {
+        return null;
+      }
+    },
     
-    GetActiveSeries: function() {
+    GetActiveSeriesInstanceUid: function() {
       var s = [];
 
       if ('tags' in this.viewport1Content.series)
@@ -689,6 +825,15 @@
       }
     },
     
+    SetViewportVirtualSeries: function(viewportIndex, seriesInstanceUid, virtualSeriesId) {
+      if (seriesInstanceUid in this.seriesIndex) {
+        this.SetViewportSeries(viewportIndex, {
+          seriesIndex: this.seriesIndex[seriesInstanceUid],
+          virtualSeriesId: virtualSeriesId
+        });
+      }
+    },
+    
     SetViewportSeries: function(viewportIndex, info) {
       var series = this.series[info.seriesIndex];
       
@@ -716,6 +861,9 @@
           virtualSeriesId: info.virtualSeriesId
         };
       }
+
+      // Give the focus to this viewport (new in Stone Web viewer 2.5)
+      this.activeViewport = viewportIndex;
     },
     
     ClickSeries: function(seriesIndex) {
@@ -1071,22 +1219,16 @@
           this.archiveJob.length > 0) {      
 
         var that = this;
-        axios.get(that.globalConfiguration.OrthancApiRoot + '/jobs/' + that.archiveJob)
+        axios.get(that.globalConfiguration.OrthancApiRoot + '/jobs/' + that.archiveJob, {
+          headers: axiosHeaders
+        })
           .then(function(response) {
             console.log('Progress of archive job ' + that.archiveJob + ': ' + response.data['Progress'] + '%');
             var state = response.data['State'];
             if (state == 'Success') {
               that.creatingArchive = false;
               var uri = that.globalConfiguration.OrthancApiRoot + '/jobs/' + that.archiveJob + '/archive';
-
-              /**
-               * The use of "window.open()" below might be blocked
-               * (depending on the browser criteria to block popup).
-               * As a consequence, we prefer to set "window.location".
-               * https://www.nngroup.com/articles/the-top-ten-web-design-mistakes-of-1999/
-               **/
-              // window.open(uri, '_blank');
-              window.location = uri;
+              TriggerDownloadFromUri(uri, that.archiveJob + '.zip', 'application/zip');
             }
             else if (state == 'Running') {
               setTimeout(that.CheckIsDownloadComplete, 1000);
@@ -1108,7 +1250,9 @@
       console.log('Creating archive for study: ' + studyInstanceUid);
 
       var that = this;
-      axios.post(this.globalConfiguration.OrthancApiRoot + '/tools/lookup', studyInstanceUid)
+      axios.post(this.globalConfiguration.OrthancApiRoot + '/tools/lookup', studyInstanceUid, {
+        headers: axiosHeaders
+      })
         .then(function(response) {
           if (response.data.length != 1) {
             throw('');
@@ -1127,12 +1271,13 @@
               // ZIP streaming is available (this is Orthanc >=
               // 1.9.4): Simply give the hand to Orthanc
               event.preventDefault();
-              window.location.href = uri;
-
+              TriggerDownloadFromUri(uri, orthancId + '.zip', 'application/zip');
             } else {
               // ZIP streaming is not available: Create a job to create the archive
               axios.post(uri, {
                 'Asynchronous' : true
+              }, {
+                headers: axiosHeaders
               })
                 .then(function(response) {
                   that.creatingArchive = true;
@@ -1145,12 +1290,74 @@
         .catch(function (error) {
           alert('Cannot find the study in Orthanc');
         });
-      
+    },
+
+    ApplyDeepLearning: function() {
+      stone.ApplyDeepLearningModel(this.GetActiveCanvas());
     },
 
-    ApplyDeepLearning: function()
-    {
-      stone.ApplyDeepLearningModel(this.GetActiveCanvas());
+    ChangeActiveSeries: function(offset) {
+      var seriesTags = this.GetActiveViewportSeriesTags();
+      if (seriesTags !== null) {
+        var studyIndex = LookupIndexOfResource(this.studies, STUDY_INSTANCE_UID, seriesTags[STUDY_INSTANCE_UID]);
+        if (studyIndex != -1) {
+          var virtualSeriesId = this.GetActiveVirtualSeries();
+          if (virtualSeriesId.length > 0) {
+            virtualSeriesId = virtualSeriesId[0];
+          } else {
+            virtualSeriesId = '';
+          }
+          
+          var seriesInStudyIndices = this.studies[studyIndex].series;
+          for (var i = 0; i < seriesInStudyIndices.length; i++) {
+            var series = this.series[seriesInStudyIndices[i]];
+            if (this.series[seriesInStudyIndices[i]].tags[SERIES_INSTANCE_UID] == seriesTags[SERIES_INSTANCE_UID]) {
+              if (series.virtualSeries !== null) {
+                for (var j = 0; j < series.virtualSeries.length; j++) {
+                  if (series.virtualSeries[j].ID == virtualSeriesId) {
+                    var next = j + offset;
+                    if (next >= 0 &&
+                        next < series.virtualSeries.length) {
+                      this.SetViewportVirtualSeries(this.activeViewport, seriesTags[SERIES_INSTANCE_UID], series.virtualSeries[next].ID);
+                    }
+                    return;
+                  }
+                }
+              }
+              else {              
+                var next = i + offset;
+                if (next >= 0 &&
+                    next < seriesInStudyIndices.length) {
+                  this.SetViewportSeriesInstanceUid(this.activeViewport, this.series[seriesInStudyIndices[next]].tags[SERIES_INSTANCE_UID]);
+                }
+                return;
+              }
+            }
+          }
+        }
+      }
+    },
+
+    ChangeActiveStudy: function(offset) {
+      var seriesTags = this.GetActiveViewportSeriesTags();
+      if (seriesTags !== null) {
+        var studyIndex = LookupIndexOfResource(this.studies, STUDY_INSTANCE_UID, seriesTags[STUDY_INSTANCE_UID]);
+        if (studyIndex != -1) {
+          var next = studyIndex + offset;
+          if (next >= 0 &&
+              next < this.studies.length) {
+            var nextStudy = this.studies[next];
+            if (nextStudy.series.length > 0) {
+              var seriesIndex = nextStudy.series[0];
+              if (this.series[seriesIndex].virtualSeries !== null) {
+                this.ClickVirtualSeries(seriesIndex, this.series[seriesIndex].virtualSeries[0].ID);
+              } else {
+                this.ClickSeries(seriesIndex);
+              }
+            }
+          }
+        }
+      }
     }
   },
   
@@ -1187,6 +1394,12 @@
       var studyInstanceUid = args.detail.studyInstanceUid;
       var seriesInstanceUid = args.detail.seriesInstanceUid;
       that.UpdateIsSeriesComplete(studyInstanceUid, seriesInstanceUid);
+
+      // Automatically open the first series to be loaded (new in Stone Web viewer 2.5)
+      if (that.isFirstSeries) {
+        that.SetViewportSeriesInstanceUid(1, seriesInstanceUid);
+        that.isFirstSeries = false;
+      }
     });
 
     window.addEventListener('StoneAnnotationAdded', function() {
@@ -1205,6 +1418,49 @@
                                 args.detail.labelX, args.detail.labelY);
       }
     });
+
+    window.addEventListener('keydown', function(event) {
+      var canvas = that.GetActiveCanvas();
+      if (canvas != '') {
+        switch (event.key) {
+        case 'Left':
+        case 'ArrowLeft':
+          stone.DecrementFrame(canvas, false);
+          break;
+
+        case 'Right':
+        case 'ArrowRight':
+          stone.IncrementFrame(canvas, false);
+          break;
+
+        case 'Up':
+        case 'ArrowUp':
+          that.ChangeActiveSeries(-1);
+          break
+          
+        case 'Down':
+        case 'ArrowDown':
+          that.ChangeActiveSeries(1);
+          break;
+
+        case 'PageUp':
+          that.ChangeActiveStudy(-1);
+          break;
+          
+        case 'PageDown':
+          that.ChangeActiveStudy(1);
+          break;
+
+        case ' ':
+        case 'Space':
+          dispatchEvent(new CustomEvent('KeyCineSwitch', { }));
+          break;
+
+        default:
+          break;
+        }
+      }
+    });
   }
 });
 
@@ -1235,9 +1491,20 @@
   // Bearer token is new in Stone Web viewer 2.0
   var token = getParameterFromUrl('token');
   if (token !== undefined) {
+    hasAuthorizationToken = true;
     stone.AddHttpHeader('Authorization', 'Bearer ' + token);
+    axiosHeaders['Authorization'] = 'Bearer ' + token;
   }
 
+  if (app.globalConfiguration.OrthancApiRoot) {
+    axios.get(app.globalConfiguration.OrthancApiRoot + '/system', {
+      headers: axiosHeaders
+    })
+      .then(function (response) {
+        app.orthancSystem = response.data;
+      });
+  }
+  
 
   /**
    * Calls to "stone.XXX()" can be reordered after this point.
@@ -1310,6 +1577,21 @@
       alert('No study, nor patient was provided in the URL!');
     }
   }
+
+  // New in Stone Web viewer 2.5
+  var menu = getParameterFromUrl('menu');
+  if (menu !== undefined) {
+    if (menu == 'hidden') {
+      app.leftVisible = false;
+    } else if (menu == 'small' ||
+               menu == 'grid' ||
+               menu == 'full') {
+      app.leftVisible = true;
+      app.leftMode = menu;
+    } else {
+      alert('Bad value for the "menu" option in the URL (can be "hidden", "small", "grid", or "full"): ' + menu);
+    }
+  }
 });
 
 
@@ -1393,13 +1675,6 @@
         .catch(function (error) {
           alert('Cannot load the WebAssembly framework');
         });
-
-      if (app.globalConfiguration.OrthancApiRoot) {
-        axios.get(app.globalConfiguration.OrthancApiRoot + '/system')
-          .then(function (response) {
-            app.orthancSystem = response.data;
-          });
-      }
     })
     .catch(function (error) {
       alert('Cannot load the configuration file');
--- a/Applications/StoneWebViewer/WebApplication/index.html	Fri Nov 18 00:37:00 2022 +0100
+++ b/Applications/StoneWebViewer/WebApplication/index.html	Mon Dec 05 08:29:49 2022 +0100
@@ -36,6 +36,7 @@
             <h3>Versions</h3>
             <p>
               Stone Web viewer: {{ stoneWebViewerVersion }} <br/>
+              <span v-if="orthancSystem.Version">Orthanc: {{ orthancSystem.Version }} <br/></span>
               Emscripten compiler: {{ emscriptenVersion }}
             </p>
           </div>
@@ -74,6 +75,7 @@
             <h3>Versions</h3>
             <p>
               Stone Web viewer: {{ stoneWebViewerVersion }} <br/>
+              <span v-if="orthancSystem.Version">Orthanc: {{ orthancSystem.Version }} <br/></span>
               Emscripten compiler: {{ emscriptenVersion }}
             </p>
             <h3>User preferences</h3>
@@ -217,7 +219,7 @@
                         <!-- 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[SERIES_INSTANCE_UID]), 'wvSerieslist__seriesItem--list' : leftMode != 'grid', 'wvSerieslist__seriesItem--grid' : leftMode == 'grid' }"
+                              v-bind:class="{ highlighted : GetActiveSeriesInstanceUid().includes(series[seriesIndex].tags[SERIES_INSTANCE_UID]), '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].virtualSeries === null">
@@ -348,9 +350,9 @@
               <div class="tbGroup">
                 <div class="tbGroup__toggl">
                   <button class="wvButton"
-                          v-bind:class="{ 'wvButton--underline' : !viewportLayoutButtonsVisible }"
+                          v-bind:class="{ 'wvButton--underline' : !viewportLayoutButtonsVisible, 'wvButton--border' : viewportLayoutButtonsVisible }"
                           data-toggle="tooltip" data-title="Change layout"
-                          @click="viewportLayoutButtonsVisible = !viewportLayoutButtonsVisible;HideAllTooltips()">
+                          @click="viewportLayoutButtonsVisible = !viewportLayoutButtonsVisible;mouseActionsVisible=false;annotationActionsVisible=false;HideAllTooltips()">
                     <i class="fa fa-th"></i>
                   </button>
                 </div>
@@ -385,9 +387,9 @@
               <div class="tbGroup">
                 <div class="tbGroup__toggl">
                   <button class="wvButton"
-                          v-bind:class="{ 'wvButton--underline' : !mouseActionsVisible }"
+                          v-bind:class="{ 'wvButton--underline' : !mouseActionsVisible, 'wvButton--border' : mouseActionsVisible }"
                           data-toggle="tooltip" data-title="Mouse actions"
-                          @click="mouseActionsVisible = !mouseActionsVisible;HideAllTooltips()">
+                          @click="viewportLayoutButtonsVisible=false;mouseActionsVisible = !mouseActionsVisible;annotationActionsVisible=false;HideAllTooltips()">
                     <i class="fa fa-mouse-pointer"></i>
                   </button>
                 </div>
@@ -540,77 +542,94 @@
               </button>
             </div>
             
-            <div class="ng-scope inline-object">
-              <button class="wvButton--underline text-center"
-                      v-bind:class="{ 'active' : mouseTool == MOUSE_TOOL_CREATE_LENGTH }"
-                      v-on:click="SetLeftMouseButtonAction(MOUSE_TOOL_CREATE_LENGTH, stone.WebViewerAction.CREATE_LENGTH)"
-                      data-toggle="tooltip" data-title="Measure length">
-                <i class="fas fa-ruler"></i>
-              </button>
-            </div>
 
             <div class="ng-scope inline-object">
-              <button class="wvButton--underline text-center"
-                      v-bind:class="{ 'active' : mouseTool == MOUSE_TOOL_CREATE_ANGLE }"
-                      v-on:click="SetLeftMouseButtonAction(MOUSE_TOOL_CREATE_ANGLE, stone.WebViewerAction.CREATE_ANGLE)"
-                      data-toggle="tooltip" data-title="Measure angle">
-                <i class="fas fa-drafting-compass"></i>
-              </button>
-            </div>
+              <div class="tbGroup">
+                <div class="tbGroup__toggl">
+                  <button class="wvButton"
+                          v-bind:class="{ 'wvButton--underline' : !annotationActionsVisible, 'wvButton--border' : annotationActionsVisible }"
+                          data-toggle="tooltip" data-title="Annotations"
+                          @click="viewportLayoutButtonsVisible=false;mouseActionsVisible=false;annotationActionsVisible = !annotationActionsVisible;HideAllTooltips()">
+                    <i class="fas fa-pencil-ruler"></i>
+                  </button>
+                </div>
+                
+                <div class="tbGroup__buttons--bottom" v-show="annotationActionsVisible">
+                  <div class="ng-scope inline-object">
+                    <button class="wvButton--underline text-center"
+                            v-bind:class="{ 'active' : mouseTool == MOUSE_TOOL_CREATE_LENGTH }"
+                            v-on:click="SetLeftMouseButtonAction(MOUSE_TOOL_CREATE_LENGTH, stone.WebViewerAction.CREATE_LENGTH)"
+                            data-toggle="tooltip" data-title="Measure length">
+                      <i class="fas fa-ruler"></i>
+                    </button>
+                  </div>
+
+                  <div class="ng-scope inline-object">
+                    <button class="wvButton--underline text-center"
+                            v-bind:class="{ 'active' : mouseTool == MOUSE_TOOL_CREATE_ANGLE }"
+                            v-on:click="SetLeftMouseButtonAction(MOUSE_TOOL_CREATE_ANGLE, stone.WebViewerAction.CREATE_ANGLE)"
+                            data-toggle="tooltip" data-title="Measure angle">
+                      <i class="fas fa-drafting-compass"></i>
+                    </button>
+                  </div>
+
+                  <div class="ng-scope inline-object">
+                    <button class="wvButton--underline text-center"
+                            v-bind:class="{ 'active' : mouseTool == MOUSE_TOOL_CREATE_CIRCLE }"
+                            v-on:click="SetLeftMouseButtonAction(MOUSE_TOOL_CREATE_CIRCLE, stone.WebViewerAction.CREATE_CIRCLE)"
+                            data-toggle="tooltip" data-title="Measure circle">
+                      <i class="far fa-circle"></i>
+                    </button>
+                  </div>
 
-            <div class="ng-scope inline-object">
-              <button class="wvButton--underline text-center"
-                      v-bind:class="{ 'active' : mouseTool == MOUSE_TOOL_CREATE_CIRCLE }"
-                      v-on:click="SetLeftMouseButtonAction(MOUSE_TOOL_CREATE_CIRCLE, stone.WebViewerAction.CREATE_CIRCLE)"
-                      data-toggle="tooltip" data-title="Measure circle">
-                <i class="far fa-circle"></i>
-              </button>
-            </div>
+                  <div class="ng-scope inline-object">
+                    <button class="wvButton--underline text-center"
+                            v-bind:class="{ 'active' : mouseTool == MOUSE_TOOL_CREATE_PIXEL_PROBE }"
+                            v-on:click="SetLeftMouseButtonAction(MOUSE_TOOL_CREATE_PIXEL_PROBE, stone.WebViewerAction.CREATE_PIXEL_PROBE)"
+                            data-toggle="tooltip" data-title="Pixel probe">
+                      <i class="fas fa-microscope"></i>
+                    </button>
+                  </div>
+
+                  <div class="ng-scope inline-object">
+                    <button class="wvButton--underline text-center"
+                            v-bind:class="{ 'active' : mouseTool == MOUSE_TOOL_CREATE_RECTANGLE_PROBE }"
+                            v-on:click="SetLeftMouseButtonAction(MOUSE_TOOL_CREATE_RECTANGLE_PROBE, stone.WebViewerAction.CREATE_RECTANGLE_PROBE)"
+                            data-toggle="tooltip" data-title="Rectangle probe">
+                      <i class="fas fa-plus-square"></i>
+                    </button>
+                  </div>
 
-            <div class="ng-scope inline-object">
-              <button class="wvButton--underline text-center"
-                      v-bind:class="{ 'active' : mouseTool == MOUSE_TOOL_CREATE_PIXEL_PROBE }"
-                      v-on:click="SetLeftMouseButtonAction(MOUSE_TOOL_CREATE_PIXEL_PROBE, stone.WebViewerAction.CREATE_PIXEL_PROBE)"
-                      data-toggle="tooltip" data-title="Pixel probe">
-                <i class="fas fa-microscope"></i>
-              </button>
+                  <div class="ng-scope inline-object">
+                    <button class="wvButton--underline text-center"
+                            v-bind:class="{ 'active' : mouseTool == MOUSE_TOOL_CREATE_ELLIPSE_PROBE }"
+                            v-on:click="SetLeftMouseButtonAction(MOUSE_TOOL_CREATE_ELLIPSE_PROBE, stone.WebViewerAction.CREATE_ELLIPSE_PROBE)"
+                            data-toggle="tooltip" data-title="Ellipse probe">
+                      <i class="fas fa-plus-circle"></i>
+                    </button>
+                  </div>
+
+                  <div class="ng-scope inline-object">
+                    <button class="wvButton--underline text-center"
+                            v-bind:class="{ 'active' : mouseTool == MOUSE_TOOL_CREATE_TEXT_ANNOTATION }"
+                            v-on:click="SetLeftMouseButtonAction(MOUSE_TOOL_CREATE_TEXT_ANNOTATION, stone.WebViewerAction.CREATE_TEXT_ANNOTATION)"
+                            data-toggle="tooltip" data-title="Add text annotation">
+                      <i class="fas fa-comment-dots"></i>
+                    </button>
+                  </div>
+
+                  <div class="ng-scope inline-object">
+                    <button class="wvButton--underline text-center"
+                            v-bind:class="{ 'active' : mouseTool == MOUSE_TOOL_REMOVE_MEASURE }"
+                            v-on:click="SetLeftMouseButtonAction(MOUSE_TOOL_REMOVE_MEASURE, stone.WebViewerAction.REMOVE_MEASURE)"
+                            data-toggle="tooltip" data-title="Delete annotation">
+                      <i class="fas fa-trash"></i>
+                    </button>
+                  </div>
+                </div>
+              </div>
             </div>
 
-            <div class="ng-scope inline-object">
-              <button class="wvButton--underline text-center"
-                      v-bind:class="{ 'active' : mouseTool == MOUSE_TOOL_CREATE_RECTANGLE_PROBE }"
-                      v-on:click="SetLeftMouseButtonAction(MOUSE_TOOL_CREATE_RECTANGLE_PROBE, stone.WebViewerAction.CREATE_RECTANGLE_PROBE)"
-                      data-toggle="tooltip" data-title="Rectangle probe">
-                <i class="fas fa-plus-square"></i>
-              </button>
-            </div>
-
-            <div class="ng-scope inline-object">
-              <button class="wvButton--underline text-center"
-                      v-bind:class="{ 'active' : mouseTool == MOUSE_TOOL_CREATE_ELLIPSE_PROBE }"
-                      v-on:click="SetLeftMouseButtonAction(MOUSE_TOOL_CREATE_ELLIPSE_PROBE, stone.WebViewerAction.CREATE_ELLIPSE_PROBE)"
-                      data-toggle="tooltip" data-title="Ellipse probe">
-                <i class="fas fa-plus-circle"></i>
-              </button>
-            </div>
-
-            <div class="ng-scope inline-object">
-              <button class="wvButton--underline text-center"
-                      v-bind:class="{ 'active' : mouseTool == MOUSE_TOOL_CREATE_TEXT_ANNOTATION }"
-                      v-on:click="SetLeftMouseButtonAction(MOUSE_TOOL_CREATE_TEXT_ANNOTATION, stone.WebViewerAction.CREATE_TEXT_ANNOTATION)"
-                      data-toggle="tooltip" data-title="Add text annotation">
-                <i class="fas fa-comment-dots"></i>
-              </button>
-            </div>
-
-            <div class="ng-scope inline-object">
-              <button class="wvButton--underline text-center"
-                      v-bind:class="{ 'active' : mouseTool == MOUSE_TOOL_REMOVE_MEASURE }"
-                      v-on:click="SetLeftMouseButtonAction(MOUSE_TOOL_REMOVE_MEASURE, stone.WebViewerAction.REMOVE_MEASURE)"
-                      data-toggle="tooltip" data-title="Delete annotation">
-                <i class="fas fa-trash"></i>
-              </button>
-            </div>
 
             <div class="ng-scope inline-object">
               <button class="wvButton--underline text-center"
@@ -754,7 +773,7 @@
 
               <div v-show="showInfo">
                 <div v-if="numberOfFrames > 1" class="wvVerticalScrollbar"
-                       v-on:click="var offset = $event.currentTarget.getClientRects()[0]; var y = $event.clientY - offset.top; var height = $event.currentTarget.offsetHeight; currentFrame = Math.min(numberOfFrames - 1, Math.floor(y * numberOfFrames / (height - 1)));">
+                     v-on:mousedown="ClickVerticalScrollbar($event)">
                   <div class="wvVerticalScrollbarHighlight"
                        v-bind:style="{ top: (currentFrame / (numberOfFrames - 1) * 95.0) + '%' }">
                   </div>
@@ -764,7 +783,8 @@
                   <div v-if="'tags' in content.series" class="wv-overlay-topleft">
                     {{ content.series.tags[PATIENT_NAME] }}<br/>
                     {{ content.series.tags[PATIENT_ID] }}<br/>
-                    {{ app.FormatDate(content.series.tags[PATIENT_BIRTH_DATE]) }}
+                    {{ app.FormatDate(content.series.tags[PATIENT_BIRTH_DATE]) }} -
+                    {{ content.series.tags[PATIENT_SEX] }}
                   </div>
                   <div v-if="'tags' in content.series" class="wv-overlay-topright"
                        v-bind:class="{ 'wvInfoRightMargin' : numberOfFrames > 1 }">
@@ -810,13 +830,13 @@
                         </button>
                       </div>
                       <div class="btn-group btn-group-sm" role="group">                        
-                        <button type="button" class="btn btn-primary" @click="CinePlay()">
+                        <button type="button" class="btn btn-primary" @click="CineBackward()">
                           <i class="fas fa-play fa-flip-horizontal"></i>
                         </button>
                         <button type="button" class="btn btn-primary" @click="CinePause()">
                           <i class="fas fa-pause"></i>
                         </button>
-                        <button type="button" class="btn btn-primary" @click="CineBackward()">
+                        <button type="button" class="btn btn-primary" @click="CinePlay()">
                           <i class="fas fa-play"></i>
                         </button>
                       </div>
--- a/Applications/StoneWebViewer/WebAssembly/StoneWebViewer.cpp	Fri Nov 18 00:37:00 2022 +0100
+++ b/Applications/StoneWebViewer/WebAssembly/StoneWebViewer.cpp	Mon Dec 05 08:29:49 2022 +0100
@@ -3530,6 +3530,23 @@
       Redraw();
     }
   }
+
+  
+  void SignalSynchronizedBrowsing()
+  {
+    if (synchronizationEnabled_ &&
+        frames_.get() != NULL &&
+        cursor_.get() != NULL)
+    {
+      const size_t currentCursorIndex = cursor_->GetCurrentIndex();
+
+      const OrthancStone::CoordinateSystem3D current =
+        frames_->GetFrameGeometry(currentCursorIndex);
+      
+      observer_->SignalSynchronizedBrowsing(
+        *this, current.GetOrigin() + synchronizationOffset_, current.GetNormal());
+    }
+  }
 };
 
 
@@ -4492,7 +4509,9 @@
   {
     try
     {
-      return GetViewport(canvas)->ChangeFrame(SeriesCursor::Action_Minus, isCircular) ? 1 : 0;
+      bool changed = GetViewport(canvas)->ChangeFrame(SeriesCursor::Action_Minus, isCircular);
+      GetViewport(canvas)->SignalSynchronizedBrowsing();
+      return changed ? 1 : 0;
     }
     EXTERN_CATCH_EXCEPTIONS;
     return 0;
@@ -4505,7 +4524,9 @@
   {
     try
     {
-      return GetViewport(canvas)->ChangeFrame(SeriesCursor::Action_Plus, isCircular) ? 1 : 0;
+      bool changed = GetViewport(canvas)->ChangeFrame(SeriesCursor::Action_Plus, isCircular);
+      GetViewport(canvas)->SignalSynchronizedBrowsing();
+      return changed ? 1 : 0;
     }
     EXTERN_CATCH_EXCEPTIONS;
     return 0;
@@ -4521,6 +4542,7 @@
       if (frameNumber >= 0)
       {
         GetViewport(canvas)->SetFrame(static_cast<unsigned int>(frameNumber));
+        GetViewport(canvas)->SignalSynchronizedBrowsing();
       }
     }
     EXTERN_CATCH_EXCEPTIONS;
@@ -4533,6 +4555,7 @@
     try
     {
       GetViewport(canvas)->GoToFirstFrame();
+      GetViewport(canvas)->SignalSynchronizedBrowsing();
     }
     EXTERN_CATCH_EXCEPTIONS;
   }
@@ -4544,6 +4567,7 @@
     try
     {
       GetViewport(canvas)->GoToLastFrame();
+      GetViewport(canvas)->SignalSynchronizedBrowsing();
     }
     EXTERN_CATCH_EXCEPTIONS;
   }
--- a/CITATION.cff	Fri Nov 18 00:37:00 2022 +0100
+++ b/CITATION.cff	Mon Dec 05 08:29:49 2022 +0100
@@ -17,5 +17,5 @@
 repository-code: 'https://hg.orthanc-server.com/orthanc-stone/'
 url: 'https://www.orthanc-server.com/'
 license: AGPL-3.0-or-later
-version: 2.4
-date-released: 2022-11-02
+version: 2.5
+date-released: 2022-12-05
--- a/OrthancStone/Sources/Scene2D/AnnotationsSceneLayer.cpp	Fri Nov 18 00:37:00 2022 +0100
+++ b/OrthancStone/Sources/Scene2D/AnnotationsSceneLayer.cpp	Mon Dec 05 08:29:49 2022 +0100
@@ -653,21 +653,34 @@
       const double yc = middle_.GetY();
       const double x2 = end_.GetX();
       const double y2 = end_.GetY();
-        
-      startAngle = atan2(y1 - yc, x1 - xc);
-      endAngle = atan2(y2 - yc, x2 - xc);
-
-      fullAngle = endAngle - startAngle;
-        
-      while (fullAngle < -PI)
+
+      double referenceAngle = atan2(y1 - yc, x1 - xc);
+      double secondAngle = atan2(y2 - yc, x2 - xc);
+
+      secondAngle -= referenceAngle;
+
+      while (secondAngle >= PI)
+      {
+        secondAngle -= 2.0 * PI;
+      }
+
+      while (secondAngle <= -PI)
       {
-        fullAngle += 2.0 * PI;
+        secondAngle += 2.0 * PI;
+      }
+
+      if (secondAngle < 0)
+      {
+        startAngle = referenceAngle + secondAngle;
+        endAngle = referenceAngle;
       }
-        
-      while (fullAngle >= PI)
+      else
       {
-        fullAngle -= 2.0 * PI;
+        startAngle = referenceAngle;
+        endAngle = referenceAngle + secondAngle;
       }
+      
+      fullAngle = endAngle - startAngle;
     }
       
   public:
--- a/TODO	Fri Nov 18 00:37:00 2022 +0100
+++ b/TODO	Mon Dec 05 08:29:49 2022 +0100
@@ -37,6 +37,9 @@
   up/down arrow keys (prev/next series).
   https://groups.google.com/g/orthanc-users/c/u_lH9aqKsdw/m/KQ7U9CkiAAAJ.
 
+* Minor: Rotate the anchors of the text after rotation of the display.
+
+
 ------------
 Known issues
 ------------