ImagePanel with autofit and zoom

I was in need of a simple control to display an image from a file. It had to autosize the image to fit within the current control size (with padding) and maintain the original aspect ratio. It also had to automatically resize when the control was resized. The following code does most of what I want. My todo list for enhancements consists of:

  1. When CTRL+SCROLL is used, zoo m in/out centred on the current mouse position
  2. Allow click-drag to pan in all directions

I’m going to continue working on the enhancements myself, but if anyone is so inclined as to modify my code to add these features I would be thrilled.

"""                                                                                 
    Name:                                                                           
                                                                                    
        ImagePanel.py                                                               
                                                                                    
    Description:                                                                    
                                                                                    
        A panel containing a wx.StaticBitmap control that can be used to display    
        an image. The image is scale to fit inside the panel while maintaining the  
        image's original aspect ratio. The image size is recaulated whenever the    
        panel is resized.                                                           
                                                                                    
        You can zoom in/out using CTRL+Scroll Wheel. The image is displayed in a    
        panel with scroll bars. If zoomed in you can scroll to see portions of the  
        image that are off the display.                                             
                                                                                    
    Methods:                                                                        
                                                                                    
        Load(file)  - load and display the image from the given file                
        Clear()     - clear the display                                             
                                                                                    
        All common image formats are supported.                                     
                                                                                    
    Audit:                                                                          
                                                                                    
        2021-07-20  rj  original code                                               
                                                                                    
"""

import wx
#import wx.lib.mixins.inspection


import wx.lib.scrolledpanel as scrolled


class ImagePanel(scrolled.ScrolledPanel):
    """
    This control implements a basic image viewer. As the control is
    resized the image is resized (aspect preserved) to fill the panel.

    Methods:

        Load(filename)   display the image from the given file
        Clear()          clear the displayed image
    """

    def __init__(self, parent, id=wx.ID_ANY,
                 pos=wx.DefaultPosition,
                 size=wx.DefaultSize,
                 style=wx.BORDER_SUNKEN
                 ):

        super().__init__(parent, id, pos, size, style=style)

        self.bmpImage = wx.StaticBitmap(self, wx.ID_ANY)
        sizer = wx.BoxSizer(wx.HORIZONTAL)
        sizer.Add(self.bmpImage, 1, wx.EXPAND, 0)
        self.SetSizer(sizer)

        self.bitmap = None  # loaded image in bitmap format
        self.image = None   # loaded image in image format
        self.aspect = None  # loaded image aspect ratio
        self.zoom = 1.0     # zoom factor

        self.blank = wx.Bitmap(1, 1)

        self.Bind(wx.EVT_SIZE, self.OnSize)
        self.Bind(wx.EVT_MOUSEWHEEL, self.OnMouseWheel)

        self.SetupScrolling()

        # wx.lib.inspection.InspectionTool().Show()

    def OnSize(self, event):
        """When panel is resized, scale the image to fit"""
        self.ScaleToFit()
        event.Skip()

    def OnMouseWheel(self, event):
        """zoom in/out on CTRL+scroll"""
        m = wx.GetMouseState()

        if m.ControlDown():
            delta = 0.1 * event.GetWheelRotation() / event.GetWheelDelta()
            self.zoom = max(1, self.zoom + delta)
            self.ScaleToFit()

        event.Skip()

    def Load(self, file: str) -> None:
        """Load the image file into the control for display"""
        self.bitmap = wx.Bitmap(file, wx.BITMAP_TYPE_ANY)
        self.image = wx.Bitmap.ConvertToImage(self.bitmap)
        self.aspect = self.image.GetSize()[1] / self.image.GetSize()[0]
        self.zoom = 1.0

        self.bmpImage.SetBitmap(self.bitmap)

        self.ScaleToFit()

    def Clear(self):
        """Set the displayed image to blank"""
        self.bmpImage.SetBitmap(self.blank)
        self.zoom = 1.0

    def ScaleToFit(self) -> None:
        """
        Scale the image to fit in the container while maintaining
        the original aspect ratio.
        """
        if self.image:

            # get container (c) dimensions
            cw, ch = self.GetSize()

            # calculate new (n) dimensions with same aspect ratio
            nw = cw
            nh = int(nw * self.aspect)

            # if new height is too large then recalculate sizes to fit
            if nh > ch:
                nh = ch
                nw = int(nh / self.aspect)

            # Apply zoom
            nh = int(nh * self.zoom)
            nw = int(nw * self.zoom)

            # scale the image to new dimensions and display
            image = self.image.Scale(nw, nh)
            self.bmpImage.SetBitmap(image.ConvertToBitmap())
            self.Layout()

            if self.zoom > 1.0:
                self.ShowScrollBars = True
                self.SetupScrolling()
            else:
                self.ShowScrollBars = False
                self.SetupScrolling()


if __name__ == "__main__":
    app = wx.App()
    frame = wx.Frame(None)
    panel = ImagePanel(frame)
    frame.SetSize(800, 625)
    frame.Show()
    panel.Load('D:\\test.jpg')
    app.MainLoop()

Hi,

I think it is hard To Do using a combination of ScrolledPanel and StaticBitmap (they are “static”).

As mentioned in the docs of StaticBitmap, native implementations on some platforms are only meant for the display of the small icons in the dialog boxes. If you have rapidly changing big images, or want to do dynamic control like pan & zoom, it is better to write your own control with PaintDC or double-buffered DC (to reduce flicker).

Another option is to use wx.lib.floatcanvas. It implements double buffering and perfect pan & zoom interface. This is a small example.

import wx
from wx.lib.floatcanvas.FCObjects import ScaledBitmap
from wx.lib.floatcanvas.FloatCanvas import FloatCanvas

class Frame(wx.Frame):
    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        
        w, h = self.GetSize()
        self.image = wx.Image("sample.png")
        self.Canvas = FloatCanvas(self, size=(w,h))
        self.bitmap = self.Canvas.AddScaledBitmap(self.image, (0,0), h, 'cc')
        self.Canvas.Draw()
        self.Canvas.Bind(wx.EVT_MOUSEWHEEL, self.OnWheel)
        self.Canvas.Bind(wx.EVT_LEFT_DCLICK, self.ZoomToFit)
    
    def OnWheel(self, event):
        self.Canvas.Zoom(
            1/1.2 if event.GetWheelRotation() < 0 else 1.2,
            event.Position, "Pixel", keepPointInPlace=True)
    
    def ZoomToFit(self,event):
        self.Canvas.ZoomToBB()

if __name__ == "__main__":
    app = wx.App()
    frm = Frame(None)
    frm.Show()
    app.MainLoop()

Unfortunately, there are no scroll bars (there may be a way to make custom scroll bars). Alternatively, the original code in “wx/lib/floatcanvas/GUIMode.py” will help you to implement pan-move.

1 Like

FloatCanvas looks like it is a good starting point for what I want and GUIMode should have some hints s to how to implement pan. Thanks.

I’ve looked at PaintDC and double buffering but unfortunately, the explanations I have found seem to be written to explain it to people who already know how they work. What I have found so far is either

  1. Too complex an example to illustrate only what I want/need to know
  2. Relies on too much other info I just don’t have yet

See here for example.

it works, but the quality of the picture (in case there has been one) is down the drain :rofl:

Are you referring to the StaticBitMap, or to the FloatCanvas?

I compared both with some more fitting picture tools (f.i. RawTherapee and a png from Canon EOS 550) and the jaggedness is simply unacceptable
as @komoto48g already hinted at, these bitmap ideas in wx must have sprung up with the icon hype of the (hopefully) past and are not meant for viewing images :worried:

Can you please be specific? I do not know what things you are comparing so I have no idea what, if anything, you are recommending.

Hi, Jim

Last month I posted a short example for using wx.DC. Please check the attached file if you like.
https://discuss.wxpython.org/t/comparison-of-rendering-speeds-of-several-toolkits-and-wx/35467/4

I have no idea the cause of the quality deterioration that da-dada pointed out. One possibility is the general problem when we resized down the picture: Aliasing or Moire.

For example, try this image.

Without any interpolation (normally ‘nearest’) you will see a pattern similar to the following:
sample_resized

This pattern can be produced like this. It is not as simple as 1+1=2… :wink:

import numpy as np

def ctf():
    N = 1024
    r = 20 * np.arange(-N/2, N/2) / N
    X, Y = np.meshgrid(r, r)
    f = np.sin(X**2 + Y**2)
    src = (255 * f ** 2).astype(np.uint8)
    buf = src.repeat(3,1).tobytes()
    return wx.Image(N, N, buf)

EDIT: Thank you this pointed out :arrow_up: Fixed.

Interesting. I appreciate the example but I’d just like to point out something I frequently see. I was able to run your code to see the results on my computer only because I was already familiar with numpy. You used np without specifying that you also need to do

import numpy as np

Anyone else reading this thread who is not familiar with numpy would be a little lost.

Thanks for the link to the DC example. It’s first on my todo list after coffee.

I went through the example named test_wx_dc2.py but I am still in the dark as to how/why things work. The wxPython docs give me the methods and properties but, again, no explanation as to how to use them.

I’m not a photographer but have done some imaging (top wx, bottom what people expect)

I see two pictures, seemingly identical, with no idea why you posted them or what I am supposed to notice.

Hi, da-dada

This is aliasing. If you want to reduce the size of the image, you should choose one of the interpolation methods.

wx.Image.Scale(width, height, quality=IMAGE_QUALITY_NORMAL) -> Image

wx.IMAGE_QUALITY_BICUBIC                2
wx.IMAGE_QUALITY_BILINEAR               1
wx.IMAGE_QUALITY_BOX_AVERAGE            3
wx.IMAGE_QUALITY_HIGH                   4
wx.IMAGE_QUALITY_NEAREST                0
wx.IMAGE_QUALITY_NORMAL                 0

scaling to screen size (I have done it outside wx) does compensate but, nevertheless, I’ll stick to my picture tools (they are in abundance & very pro like) :wink:

What, if anything, does this have to do with the topic of this thread? Perhaps your discussion belongs in a separate thread.