Slow wx.Bitmap scaling via GraphicsContext when reducing size

I previously used cv2 for scaling, then discovered that if you do it in hardware via a GraphicsContext it’s so fast it’s virtually free! However, there’s a strange problem:

It’s fine scaling up, or scaling down if the scale is more than 75%. As soon as it dips below that it suddenly takes 20x as long.

I’m using:

Python: 3.8.10 (default, Nov 22 2023, 10:22:35)
WXPython: 4.1.1 gtk3 (phoenix) wxWidgets 3.1.5
WX renderer: wxCairoRenderer (1, 16, 0)
I’ve tried this on two machines. One is on Ubuntu 18, the other on Ubuntu 20. Both are using the iGPU on Intel CPUs, one is a modest N4200, the other a recent i5.

I’m not a wxPython expert so maybe I’ve done something daft, or maybe there’s a bug?

I made a minimal complete script in case you want to replicate the problem, though it’s still quite big, sorry about that:

import sys
import wx
import time
import cv2
import numpy as np
from collections import deque


class MainFrame(wx.Frame):
    def __init__(self):
        wx.Frame.__init__(self,
                          None,
                          id=wx.ID_ANY,
                          title="Image Scaling Test",
                          pos=wx.DefaultPosition,
                          size=wx.DefaultSize
                          )

        self.bgColour = wx.Colour([64, 64, 64])
        self.background_brush = wx.Brush(self.bgColour, wx.SOLID)

        self.initUI()
        self.initVideo()

        self.ShowFullScreen(True)

        self.firstRun = True
        
        self.timer1 = wx.Timer(self, id=1) 
        self.Bind(wx.EVT_TIMER, self.on_timer1, id=1)
        self.timer1.Start(10)         


    def initUI(self):

        self.panel_controls = wx.Panel(self)
        self.panel_controls.SetBackgroundColour(self.bgColour)

        controlsSizer = wx.BoxSizer(wx.HORIZONTAL)

        self.slider_scale = wx.Slider(self, value=100, minValue=10, maxValue=200, style=wx.SL_HORIZONTAL|wx.SL_AUTOTICKS)
        self.Bind(wx.EVT_SLIDER, self.onSlider)
        
        controlsSizer.Add(self.slider_scale, proportion=1, flag=wx.EXPAND, border=0)
        self.panel_controls.SetSizer(controlsSizer)
 
        ###
        self.panel_video = wx.Panel(self)
        
        self.panel_video.SetBackgroundStyle(wx.BG_STYLE_PAINT)
        self.panel_video.Bind(wx.EVT_PAINT, self.on_paint)

        ###
        mainSizer = wx.BoxSizer(wx.VERTICAL)

        mainSizer.Add(self.panel_controls, proportion=1, flag=wx.EXPAND, border=0)
        mainSizer.Add(self.panel_video, proportion=9, flag=wx.EXPAND, border=0)

        self.SetSizer(mainSizer)
        self.Layout()


    def initVideo(self):

        self.vidCapDevice = cv2.VideoCapture()
        self.vidCapDevice.open(0, apiPreference=cv2.CAP_V4L2)
        requestCapWidth = 1024
        requestCapHeight = 576
        self.vidCapDevice.set(cv2.CAP_PROP_FRAME_WIDTH, requestCapWidth)
        self.vidCapDevice.set(cv2.CAP_PROP_FRAME_HEIGHT, requestCapHeight)
        self.capWidth = int(self.vidCapDevice.get(3))
        self.capHeight = int(self.vidCapDevice.get(4))
        self.vidCapDevice.set(cv2.CAP_PROP_FPS, 30)    

        self.latencyBuff_left = deque(maxlen=10)
        self.latencyBuff_right = deque(maxlen=10)

        self.stats_left = []
        self.stats_right = []

        self.textCoordsList_left = []
        self.textCoordsList_right = []

        self.statsInterval = 1000 # ms
        self.statsTimer = 0
        
        self.rightScale = 1.2 
        self.leftScale = 1.0  # from slider


    def on_timer1(self, event):

        self.panel_video.Refresh() # indirectly calls on_paint()


    def on_paint(self, event):

        self.updateVideoPanel()


    def onSlider(self, event):
        # We want a range of 0.1 - 2.0 but sliders only use ints,
        # so set slider range to 10 - 200 and scale it by 0.01
        obj = event.GetEventObject()
        sliderVal = obj.GetValue() 
        self.leftScale = 0.01 * sliderVal


    def updateVideoPanel(self):

        ret, capture = self.vidCapDevice.read() # blocks until result    
        capture = cv2.cvtColor(capture, cv2.COLOR_BGR2RGB).astype(np.uint8)

        bitmap =  wx.Bitmap.FromBuffer(capture.shape[1], capture.shape[0], capture)
        
        dc = wx.AutoBufferedPaintDC(self.panel_video)
        gc = wx.GraphicsContext.Create(dc)
        
        targetPanelWidth, targetPanelHeight = self.panel_video.GetSize()

        gc.SetBrush(self.background_brush)
        gc.DrawRectangle(0, 0, targetPanelWidth, targetPanelHeight)

        bitmapWidth = bitmap.GetWidth()
        bitmapHeight = bitmap.GetHeight()

        leftWidth = int(bitmapWidth * self.leftScale)
        leftHeight = int(bitmapHeight * self.leftScale)

        rightWidth = int(bitmapWidth * self.rightScale)
        rightHeight = int(bitmapHeight * self.rightScale)        

        left_padding = 10
        top_padding = 10
        gapWidth = 10

        self.drawBitmap_gc(gc, bitmap, 
                        left_padding, 
                        top_padding, 
                        leftWidth, 
                        leftHeight, 
                        self.latencyBuff_left)
        
        self.drawBitmap_gc(gc, bitmap, 
                        left_padding + leftWidth + gapWidth, 
                        top_padding, 
                        rightWidth, 
                        rightHeight, 
                        self.latencyBuff_right)

        dc.SetTextForeground(wx.WHITE)
        dc.SetFont((wx.Font(24, wx.FONTFAMILY_SWISS, wx.FONTSTYLE_NORMAL, wx.FONTWEIGHT_NORMAL)))
        
        now = time_milli()
        if now - self.statsTimer > self.statsInterval:

            self.statsTimer = now

            self.stats_left = []
            self.stats_left += [str(f"capture size: {self.capWidth}x{self.capHeight}")]
            self.stats_left += [str(f"output scale: {self.leftScale:.2f}")]
            self.stats_left += [str(f"draw time: {np.average(self.latencyBuff_left):.1f}ms")]

            self.stats_right = []
            self.stats_right += [str(f"capture size: {self.capWidth}x{self.capHeight}")]
            self.stats_right += [str(f"output scale: {self.rightScale:.2f}")]
            self.stats_right += [str(f"draw time: {np.average(self.latencyBuff_right):.1f}ms")]
            
            self.textCoordsList_left = []
            for i, metric in enumerate(self.stats_left):
                self.textCoordsList_left.append( tuple([left_padding + 10, 
                                                top_padding + (40 * i+1)]) )   

            self.textCoordsList_right = []
            for i, metric in enumerate(self.stats_right):
                self.textCoordsList_right.append( tuple([leftWidth + gapWidth + left_padding + 10, 
                                                    top_padding + (40 * i+1)]) )  

        dc.DrawTextList(self.stats_left, self.textCoordsList_left)
        dc.DrawTextList(self.stats_right, self.textCoordsList_right)        


    def drawBitmap_gc(self, gc, bitmap, left_padding, top_padding, width, height, latencyBuff):

        if self.firstRun:
            gr = gc.GetRenderer()
            print("WX renderer:", gr.GetType() , gr.GetVersion())
            self.firstRun = False

        now = time_milli()
        gc.DrawBitmap(bitmap, left_padding, top_padding, width, height)
        latencyBuff.append(time_milli() - now)                


def time_milli():

    return time.time() * 1000


def main():

    print("Python:", sys.version)
    print("WXPython:", wx.version())

    app = wx.App()  
    frame = MainFrame() 
    frame.Show(True)     
    app.MainLoop()


if __name__ == "__main__":

    main()


I also noticed that aside from this problem, whichever draw happens second is about twice as quick as the first. Maybe I’m supposed to be re-using my dc or gc?

thanks in advance for looking at this!

Edit:
updated to
WXPython: 4.2.1 gtk3 (phoenix) wxWidgets 3.2.2.1
and it’s still the same

1 Like

Hi,

I reproduced your results using wxPython 4.2.1 + Python 3.10.12 + Linux Mint 21.3, using a webcam as the input source.

I haven’t spotted anything in your code that looks like it might be causing the issue.

I noticed that if I set the slider so the scale of the left hand bitmap is exactly 0.50 then the draw time drops to similar values that I get when the scale is greater than 0.75. If I move it to 0.49 or 0.51, then it jumps back up to larger values again.

1 Like

Thanks for having a look.

I tried it again and this time noticed that at 50% it’s full speed again same as you, which kind of makes sense as whatever algorithm it is could do that a much simpler way. As i slide down it gradually speeds up from ~12ms to ~6ms at the minimum of 10%, which also makes some sense as there’s less data to shift after a certain point in the algorithm. But why is <76% so much slower than >=76%? It’s nuts, has to be a bug surely?

What hardware are you on? and did the console show WX was using Cairo same as me?

I have the same Cairo version as you: WX renderer: wxCairoRenderer (1, 16, 0)

I have now tested on both of my linux PCs which are running the same versions of wxPython, Python and Mint.

'Pavilion` is an old HP machine. Its original graphics card failed and the only card I’ve found that is compatible is an underpowered Nvidia GF119 [GeForce GT 610].

‘Avalon’ is a more modern custom built machine which has an NVIDIA GA104 [GeForce RTX 3060 Ti Lite Hash Rate].

Here are the detailed specs for both machines: PC_specs.zip (6 KB)

Here are some sample timings from both machines:

On Pavilion:

1.00  3.5ms  2.0ms
0.76  3.7ms  2.0ms
0.75 26.4ms  1.9ms
0.74 27.5ms  1.7ms

On Avalon:

1.00  4.8ms  2.6ms
0.76  4.8ms  2.8ms
0.75 20.9ms  0.7ms
0.74 22.0ms  0.6ms
1 Like

I hacked your code a bit so it could use the following method instead of self.drawBitmap_gc()

    def drawBitmap_dc(self, dc, capture, left_padding, top_padding, width, height, latencyBuff):
        now = time_milli()
        cv_image = cv2.resize(capture, (width, height), interpolation=cv2.INTER_CUBIC)
        bitmap = wx.Bitmap.FromBuffer(width, height, cv_image)
        dc.DrawBitmap(bitmap, left_padding, top_padding)
        latencyBuff.append(time_milli() - now)

Here are some sample timings for my 2 PCs:

Pavilion:

1.10  6.1ms  7.8ms
1.00  3.9ms  8.5ms
0.76  3.9ms  9.6ms
0.75  3.8ms  9.5ms
0.74  3.7ms  9.9ms
0.30  1.1ms  9.6ms

Avalon:

1.10  8.0ms  6.9ms
1.00  4.5ms  9.0ms
0.76  3.4ms  9.1ms
0.75  3.4ms  9.9ms
0.74  3.3ms  9.1ms
0.30  1.1ms  9.1ms

As expected, there is no step change at 0.75.

However, draw times for scaling > 1.00 are noticeably longer than for the graphics context method.

2 Likes

You put so much more effort into that than I could reasonably expect, thankyou!

So -

1 - it’s not Intel’s problem, as it happens on a powerful Nvidia card.

2 - I could implement a workaround:

if scale >= 0.76:
  do scaling in DrawBitmap()
if scale < 0.76:
  do scaling in cv2.resize() # or something other than gc
3 Likes

In my journey, yes, wx.Bitmap has been quite a bit slower rendering than wx.Image operations.
… so if you have a bunch of image mods you need to do it is best to do them before trying to Bitmap/Draw them on the screen. This could very much be improved on MSW but it apparently has a bit to do with how wxWidgets is writing code for each specific operation. Hopefully this will become their area of graphics optimization…

I have many small programs that try to load 1000’s of wx.bitmaps, or wx.images or seem simply fastest by embedding them into your python script(wx.PyEmbeddedImage) (especially after optimizing *.png’s with a file optimizer for size)

Most of my programs are written so that this “format” is interchangeable or ‘whatever you like it as … will try to load …’
… but in my observations it is as far as performance is concerned …
wx.PyEmbeddedImages(fastest),
wx.Images(depends, but most likely),
wx.Bitmaps(being the slowest to render).

(type) .DC Drawing is somewhere in amongst all that.

DC.DrawBitmap(Bla, bla, bla) is probably the most slowest rendering in python and could be looked at in C/C++ code depending on the situation. It definitely needs more work!

1 Like

Testing on two different rigs with different graphics cards/cpus won’t help your benchmark.

This would be STRICKLY accurate if you have 2 SAME MACHINES, but only testing the difference in code(at the python level / or the c/c++level )


The last time ‘The Elder Scrolls’ was tamed it was done with 2 GTS 8800s. Yep two(SLI). Take the other out of your I wanna be the graphics god case and stick the other into it’s twin case(same specs) and run it.

1 Like

Interesting, thankyou!