From 8cc5f021e81b956ddfe076e5abc952b05a0ed03f Mon Sep 17 00:00:00 2001
From: Paul McCarthy <pauld.mccarthy@gmail.com>
Date: Tue, 24 Jun 2014 11:41:24 +0100
Subject: [PATCH] Reworked lightbox canvas, bringing it in line with
 SliceCanvas refactors. Is now functional and reasonably clear to follow.

---
 fsl/fslview/lightboxcanvas.py | 272 ++++++++++++++++++++--------------
 1 file changed, 159 insertions(+), 113 deletions(-)

diff --git a/fsl/fslview/lightboxcanvas.py b/fsl/fslview/lightboxcanvas.py
index ba4f46334..5a3612a82 100644
--- a/fsl/fslview/lightboxcanvas.py
+++ b/fsl/fslview/lightboxcanvas.py
@@ -21,10 +21,16 @@ import fsl.props               as props
 
 
 class LightBoxCanvas(slicecanvas.SliceCanvas):
+    """
+    An OpenGL canvas which displays multiple slices from a collection of 3D
+    images (see fsl.data.fslimage.ImageList). The slices are laid out on the
+    same canvas along rows and columns, with the slice at the minimum Z
+    position translated to the top left of the canvas, and the slice with
+    the maximum Z value translated to the bottom right.
+    """
 
-    # Properties which control the starting and end bounds of the
-    # displayed slices, and the spacing between them (in real
-    # world coordinates)
+    # This property controls the spacing between
+    # slices (in real world coordinates)
     sliceSpacing = props.Real(clamped=True, minval=0.1, default=1.0)
 
     # This property controls the number of slices
@@ -32,7 +38,7 @@ class LightBoxCanvas(slicecanvas.SliceCanvas):
     ncols = props.Int(clamped=True, minval=1, maxval=15, default=5)
 
     # These properties control the range, in world
-    # coordinates, of slices to be displayed
+    # coordinates, of the slices to be displayed
     zmin = props.Real(clamped=True)
     zmax = props.Real(clamped=True)
 
@@ -49,6 +55,16 @@ class LightBoxCanvas(slicecanvas.SliceCanvas):
                           'ncols',
                           'zax'))
 
+    
+    def canvasToWorld(self, xpos, ypos):
+        """
+        Given pixel x/y coordinates on this canvas, translates them
+        into the real world x/y/z coordinates of the displayed slice.
+        What order should the returned coordinates be in?
+        """ 
+        pass
+
+        
     def __init__(self,
                  parent,
                  imageList,
@@ -76,10 +92,23 @@ class LightBoxCanvas(slicecanvas.SliceCanvas):
             raise RuntimeError('LightBoxCanvas only supports '
                                'a vertical scrollbar')
 
+        # These attributes are used to keep track of the total number
+        # of displayed slices, the total number of rows, and the total
+        # number of  rows displayed on the screen at once. If a
+        # scrollbar was not passed in, all slices are displayed on the
+        # canvas. Otherwise only a subset are displayed, but the user
+        # is able to scroll through the slices. We're initialising
+        # these attributes before SliceCanvas.__init__, because they
+        # are required by the _updateDisplayBounds method, which ends
+        # up getting called from SliceCanvas.__init__.
+        self._scrollbar    = scrollbar
+        self._nslices      = 0
+        self._nrows        = 0
+        self._rowsOnScreen = 0
+
         slicecanvas.SliceCanvas.__init__(
             self, parent, imageList, zax, glContext)
 
-        self._scrollbar = scrollbar
         if scrollbar is not None:
 
             # Trigger a redraw whenever the scrollbar is scrolled
@@ -89,8 +118,13 @@ class LightBoxCanvas(slicecanvas.SliceCanvas):
                 # - the scrollbar is not currently needed
                 if scrollbar.GetPageSize() >= scrollbar.GetRange():
                     scrollbar.SetThumbPosition(0)
-                    return
-                self._draw(ev)
+                    
+                # otherwise, figure out the area
+                # to be displayed, and redraw
+                else:
+                    self._updateDisplayBounds()
+                    self.Refresh()
+                
             scrollbar.Bind(wx.EVT_SCROLL, onScroll)
 
         # default to showing the entire slice range
@@ -102,27 +136,73 @@ class LightBoxCanvas(slicecanvas.SliceCanvas):
         # across all images in the list
         if len(imageList) > 0:
             self.sliceSpacing = min([i.pixdim[self.zax] for i in imageList])
-
-        # Called when any of the slice properties change
+ 
+        # Called when any of the slice properties
+        # change. Regenerates slice locations and
+        # display bounds, and redraws
         def sliceRangeChanged(*a):
+            self._slicePropsChanged()
+            self._updateDisplayBounds()
             self._genSliceLocations()
             self._updateScrollBar()
             self.Refresh()
-
         sliceRangeChanged()
 
-        self.addListener('sliceSpacing', self.name, sliceRangeChanged)
-        self.addListener('ncols',        self.name, sliceRangeChanged)
-        self.addListener('zmin',         self.name, sliceRangeChanged)
-        self.addListener('zmax',         self.name, sliceRangeChanged)
+        self.addListener('sliceSpacing',  self.name, sliceRangeChanged)
+        self.addListener('ncols',         self.name, sliceRangeChanged)
+        self.addListener('zmin',          self.name, sliceRangeChanged)
+        self.addListener('zmax',          self.name, sliceRangeChanged)
+
+        # Called on canvas resizes. Recalculates
+        # the number of rows to be displayed, and
+        # the display bounds, and redraws.
+        def onResize(ev):
+            self._slicePropsChanged()
+            self._updateDisplayBounds()
+            self._updateScrollBar()
+            self.Refresh()
+            ev.Skip()
+
+        self.Bind(wx.EVT_SIZE, onResize)
+
+
+    def _slicePropsChanged(self, *a):
+        """
+        Gets called whenever any of the properties which define the
+        number and layout of the lightbox slices, change. Calculates
+        the total number of slices to be displayed, the total number
+        of rows, and the number of rows to be displayed on screen
+        (which is different from the former if the canvas has a scroll
+        bar).
+        """
+        zlen = self.zmax - self.zmin
+        
+        self._nslices = int(np.floor(zlen / self.sliceSpacing))
+        self._nrows   = int(np.ceil(self._nslices / float(self.ncols)))
+
+        xlen = self.imageList.bounds.getLen(self.xax)
+        ylen = self.imageList.bounds.getLen(self.yax)
 
-        # The _updateScrollBar method is ultimately
-        # responsible for calculating the number of
-        # rows which are to be displayed on the
-        # canvas. So when the canvas resizes, we
-        # want this to be recalculated.
-        self.Bind(wx.EVT_SIZE, lambda ev: self._updateScrollBar())
+        # no scrollbar -> display all rows
+        if self._scrollbar is None:
+            self._rowsOnScreen = self._nrows
+
+        # scrollbar -> display a selection of rows
+        else:
+            width, height = self.GetClientSize().Get()
+            sliceWidth    = width / self.ncols
+            sliceHeight   = sliceWidth * (ylen / xlen)
 
+            self._rowsOnScreen = int(np.ceil(height / sliceHeight))
+
+        if self._rowsOnScreen == 0:          self._rowsOnScreen = 1
+        if self._rowsOnScreen > self._nrows: self._rowsOnScreen = self._nrows
+
+        log.debug('{: 5.1f} - {: 5.1f}: slices={} rows={} ({} on screen) '
+                  'columns={}'.format(self.zmin, self.zmax, self._nslices,
+                                      self._nrows, self._rowsOnScreen,
+                                      self.ncols))
+        
 
     def _zAxisChanged(self, *a):
         """
@@ -131,20 +211,19 @@ class LightBoxCanvas(slicecanvas.SliceCanvas):
         implementation, and then sets the slice zmin/max bounds
         to the image bounds.
         """
-
         slicecanvas.SliceCanvas._zAxisChanged(self, *a)
         
         self.zmin = self.imageList.bounds.getLo(self.zax)
         self.zmax = self.imageList.bounds.getHi(self.zax)
 
 
-    def _updateBounds(self, *a):
+    def _imageBoundsChanged(self, *a):
         """
-        Overrides SliceCanvas._updateBounds. Called when the image bounds
-        change. Updates the Z axis min/max values.
+        Overrides SliceCanvas._imageBoundsChanged. Called when
+        the image bounds change. Updates the Z axis min/max values.
         """
 
-        slicecanvas.SliceCanvas._updateBounds(self)
+        slicecanvas.SliceCanvas._imageBoundsChanged(self)
 
         imgzmin = self.imageList.bounds.getLo(self.zax)
         imgzmax = self.imageList.bounds.getHi(self.zax)
@@ -157,7 +236,35 @@ class LightBoxCanvas(slicecanvas.SliceCanvas):
         # reset zmin/zmax in case they are
         # out of range of the new image bounds
         self.zmin = imgzmin
-        self.zmax = imgzmax 
+        self.zmax = imgzmax
+
+        
+    def _updateDisplayBounds(self):
+        """
+        Overrides SliceCanvas._updateDisplayBound. Called on
+        canvas resizes, image bound changes and lightbox slice
+        property changes. Calculates the required bounding box
+        that is to be displayed, in real world coordinates.
+        """
+
+        xmin = self.imageList.bounds.getLo( self.xax)
+        ymin = self.imageList.bounds.getLo( self.yax)
+        xlen = self.imageList.bounds.getLen(self.xax)
+        ylen = self.imageList.bounds.getLen(self.yax)
+
+        if self._scrollbar is not None:
+
+            off = (self._nrows -
+                   self._scrollbar.GetThumbPosition() -
+                   self._rowsOnScreen)
+
+            ymin = ymin + ylen * off
+
+        xmax = xmin + xlen * self.ncols
+        ymax = ymin + ylen * self._rowsOnScreen
+
+        slicecanvas.SliceCanvas._updateDisplayBounds(
+            self, xmin, xmax, ymin, ymax)
 
 
     def _genSliceLocations(self):
@@ -174,15 +281,9 @@ class LightBoxCanvas(slicecanvas.SliceCanvas):
         # of all slices to be displayed on the canvas
         sliceLocs = np.arange(
             self.zmin + self.sliceSpacing * 0.5,
-            self.zmax + self.sliceSpacing,
+            self.zmax,
             self.sliceSpacing)
 
-        self._nslices = len(sliceLocs)
-        self._nrows   = int(np.ceil(self._nslices / float(self.ncols)))
-
-        log.debug('{: 5.1f} - {: 5.1f}: {} slices {} rows {} columns'.format(
-            self.zmin, self.zmax, self._nslices, self._nrows, self.ncols))
-        
         self._sliceIdxs  = []
         self._transforms = []
 
@@ -218,15 +319,15 @@ class LightBoxCanvas(slicecanvas.SliceCanvas):
 
         xform = np.array(image.voxToWorldMat, dtype=np.float32)
 
-        row = nrows - int(np.floor(sliceno / ncols)) - 1
+        row = int(np.floor(sliceno / ncols))
         col = int(np.floor(sliceno % ncols))
 
-        xlen = self.displayBounds.xlen
-        ylen = self.displayBounds.ylen
+        xlen = self.imageList.bounds.getLen(self.xax)
+        ylen = self.imageList.bounds.getLen(self.yax)
 
         translate              = np.identity(4, dtype=np.float32)
         translate[3, self.xax] = xlen * col
-        translate[3, self.yax] = ylen * row
+        translate[3, self.yax] = ylen * (nrows - row - 1)
         translate[3, self.zax] = 0
         
         return xform.dot(translate)
@@ -236,7 +337,7 @@ class LightBoxCanvas(slicecanvas.SliceCanvas):
         """
         If a scroll bar was passed in when this LightBoxCanvas was created,
         this method updates it to reflect the current state of the canvas
-        size and the displayed list of images.  
+        size and the displayed list of slices.
         """
         
         if self._scrollbar is None: return
@@ -245,27 +346,26 @@ class LightBoxCanvas(slicecanvas.SliceCanvas):
             self._scrollbar.SetScrollbar(0, 0, 0, 0, True)
             return
 
+        imgBounds  = self.imageList.bounds
+        imgxlen    = imgBounds.getLen(self.xax)
+        imgylen    = imgBounds.getLen(self.yax)
         dispBounds = self.displayBounds
+
         screenSize = self.GetClientSize()
-        
-        sliceRatio = dispBounds.xlen / dispBounds.ylen
-        
-        sliceWidth   = screenSize.width / float(self.ncols)
-        sliceHeight  = sliceWidth * sliceRatio
-        
-        rowsOnScreen = int(np.floor(screenSize.height / sliceHeight))
-        oldPos       = self._scrollbar.GetThumbPosition()
 
-        if rowsOnScreen == 0:
-            rowsOnScreen = 1
+        if screenSize.width  == 0 or \
+           screenSize.height == 0 or \
+           dispBounds.xlen   == 0 or \
+           dispBounds.ylen   == 0 or \
+           imgxlen           == 0 or \
+           imgylen           == 0:
+            return
 
-        if rowsOnScreen > self._nrows:
-            rowsOnScreen = self._nrows
+        rowsOnScreen = self._rowsOnScreen
+        oldPos       = self._scrollbar.GetThumbPosition()
 
-        log.debug('Slice size {:3.0f} x {:3.0f}, '
-                  'position: {}, '
+        log.debug('Slice row: {}, '
                   'rows on screen: {} / {}'.format(
-                      sliceWidth, sliceHeight,
                       oldPos, rowsOnScreen, self._nrows))
 
         self._scrollbar.SetScrollbar(oldPos,
@@ -274,62 +374,6 @@ class LightBoxCanvas(slicecanvas.SliceCanvas):
                                      rowsOnScreen,
                                      True)
 
-
-    def _calculateCanvasBBox(self):
-        """
-        Calculates the bounding box for slices to be displayed
-        on the canvas, such that their aspect ratio is maintained.
-        """
-
-        worldSliceWidth  = float(self.displayBounds.xlen)
-        worldSliceHeight = float(self.displayBounds.ylen)
-
-        # If there's a scrollbar, its pagesize
-        # value contains the number of rows
-        # to be displayed on the screen - see
-        # the _updateScrollBar method
-        if self._scrollbar is not None:
-            rowsOnScreen = self._scrollbar.GetPageSize()
-            worldWidth   = worldSliceWidth  * self.ncols
-            worldHeight  = worldSliceHeight * rowsOnScreen
-
-        # If there's no scrollbar, we display
-        # all the slices on the screen
-        else:
-            worldWidth   = worldSliceWidth  * self.ncols
-            worldHeight  = worldSliceHeight * self._nrows
-
-        slicecanvas.SliceCanvas._calculateCanvasBBox(self,
-                                                     worldWidth=worldWidth,
-                                                     worldHeight=worldHeight)
-
-
-    def _setViewport(self):
-        """
-        Sets up the GL canvas size, viewport and projection.
-        """
-
-        xlen = self.displayBounds.xlen
-        ylen = self.displayBounds.ylen
-
-        worldYMin  = None
-        worldXMax  = self.displayBounds.xlo + xlen * self.ncols
-        worldYMax  = self.displayBounds.ylo + ylen * self._nrows
-
-        if self._scrollbar is not None:
-
-            rowsOnScreen = self._scrollbar.GetPageSize()
-            currentRow   = self._scrollbar.GetThumbPosition()
-            currentRow   = self._nrows - currentRow - rowsOnScreen
-
-            worldYMin = self.displayBounds.ylo + ylen * currentRow
-            worldYMax = worldYMin              + ylen * rowsOnScreen
-
-        slicecanvas.SliceCanvas._setViewport(self,
-                                             xmax=worldXMax,
-                                             ymin=worldYMin,
-                                             ymax=worldYMax)
-
         
     def _draw(self, ev):
         """
@@ -374,10 +418,11 @@ class LightBoxCanvas(slicecanvas.SliceCanvas):
         gl.glShadeModel(gl.GL_FLAT)
 
         # Draw all the slices for all the images.
-        
         for i, image in enumerate(self.imageList):
-            log.debug('Drawing {} slices for image {}'.format(
-                endSlice - startSlice, i))
+            
+            log.debug('Drawing {} slices ({} - {}) for image {}'.format(
+                endSlice - startSlice, startSlice, endSlice, i))
+            
             for zi in range(startSlice, endSlice):
                 self._drawSlice(image,
                                 self._sliceIdxs[ i][zi],
@@ -432,7 +477,8 @@ class LightBoxPanel(wx.Panel):
                 return
             
             self.scrollbar.SetThumbPosition(curPos + wheelDir)
-            self.canvas._draw(None)
+            self.canvas._updateDisplayBounds()
+            self.canvas.Refresh()
 
         self.Bind(wx.EVT_MOUSEWHEEL, scrollOnMouse)
 
-- 
GitLab