Pixel boundaries in zoomed image on FloatCanvas

Hello, I try to set guides on an image displayed in a FloatCanvas. The application I’m building is quite large but I reduced it as much as possible to show the problem. The next picture shows the startup screen of this reduced test code.


When zoomed-in you can see that the guide does not coincide with the pixel boundaries.
Sorry, but the picture here was not allowed (new user).
Zooming is done with Ctrl key and mouse wheel. After zooming in and out several times, you may be lucky and get the desired result.
Sorry, but the picture here was not allowed (new user).
My code for testing is the file pixzoomtst.py. I tried to upload it but since I’m new it is not allowed.
Maybe pasting can:

#! python3
# -*- coding: utf-8 -*-

import wx
import wx.lib.floatcanvas.FloatCanvas
import wx.lib.floatcanvas.FCEvents
import wx.lib.floatcanvas.FCObjects
import numpy as N
from wx.lib.floatcanvas.Utilities import BBox

class ScBitmap(wx.lib.floatcanvas.FCObjects.ScaledBitmap2):
    def __init__(self,
                 Bitmap,
                 XY,
                 Height,
                 Width=None,
                 Position = 'tl',
                 InForeground = False):
        wx.lib.floatcanvas.FCObjects.ScaledBitmap2.__init__(self, Bitmap,
                 XY, Height, Width, Position, InForeground)

    def _DrawSubBitmap(self, dc , WorldToPixel, ScaleWorldToPixel, HTdc):
        """
        Subsets just the part of the bitmap that is visible
        then scales and draws that.

        """
        BBworld = BBox.asBBox(self._Canvas.ViewPortBB)
        BBbitmap = BBox.fromPoints(self.WorldToBitmap(BBworld))

        XYs = WorldToPixel(self.XY)
        # figure out subimage:
        # fixme: this should be able to be done more succinctly!

        if BBbitmap[0,0] < 0:
            Xb = 0
        elif BBbitmap[0,0] > self.bmpWH[0]: # off the bitmap
            Xb = 0
        else:
            Xb = int(BBbitmap[0,0])
            XYs[0] = 0 # draw at origin

        if BBbitmap[0,1] < 0:
            Yb = 0
        elif BBbitmap[0,1] > self.bmpWH[1]: # off the bitmap
            Yb = 0
            ShouldDraw = False
        else:
            Yb = int(BBbitmap[0,1])
            XYs[1] = 0 # draw at origin

        if BBbitmap[1,0] < 0:
            #off the screen --  This should never happen!
            Wb = 0
        elif BBbitmap[1,0] > self.bmpWH[0]:
            Wb = self.bmpWH[0] - Xb
        else:
            Wb = int(BBbitmap[1,0]) - Xb

        if BBbitmap[1,1] < 0:
            # off the screen --  This should never happen!
            Hb = 0
            ShouldDraw = False
        elif BBbitmap[1,1] > self.bmpWH[1]:
            Hb = self.bmpWH[1] - Yb
        else:
            Hb = int(BBbitmap[1,1]) - Yb

        FullHeight = ScaleWorldToPixel(self.Height)[0]
        scale = float(FullHeight) / float(self.bmpWH[1])
        Ws = int(scale * Wb + 0.5) # add the 0.5 to  round
        Hs = int(scale * Hb + 0.5)
        if (self.ScaledBitmap is None) or (self.ScaledBitmap[0] != (Xb, Yb, Wb, Hb, Ws, Ws)):
            Img = self.Image.GetSubImage(wx.Rect(Xb, Yb, Wb, Hb))
            #print("rescaling with High quality")
            if scale < 1.0:
                Img.Rescale(int(Ws), int(Hs), quality=wx.IMAGE_QUALITY_HIGH)
            else:
                Img.Rescale(int(Ws), int(Hs), quality=wx.IMAGE_QUALITY_NORMAL)
            bmp = wx.Bitmap(Img)
            self.ScaledBitmap = ((Xb, Yb, Wb, Hb, Ws, Ws), bmp)# this defines the cached bitmap
            #XY = self.ShiftFun(XY[0], XY[1], W, H)
            #fixme: get the shiftfun working!
        else:
            #print("Using cached bitmap")
            ##fixme: The cached bitmap could be used if the one needed is the same scale, but
            ##       a subset of the cached one.
            bmp = self.ScaledBitmap[1]
        dc.DrawBitmap(bmp, XYs, True)

        if HTdc and self.HitAble:
            HTdc.SetPen(self.HitPen)
            HTdc.SetBrush(self.HitBrush)
            HTdc.DrawRectangle(XYs, (Ws, Hs) )

class Guide(wx.lib.floatcanvas.FCObjects.Line):
    """
    Draws a guide line vertical or horizontal.

    It will draw a straight vertical line at the x-coordinate 
    or a straight horizontal line at the y-coordinate.
    These lines will extend to the viewport boundaries.

    """
    def __init__(self, Points,
                 LineColor = "Blue",
                 LineStyle = "ShortDash",
                 LineWidth    = 1,
                 InForeground = True,
                 GuideMode = wx.VERTICAL):
        """
        Default class constructor.

        :param `Points`: takes a 2-tuple, or a (2,)
         `NumPy <http://www.numpy.org/>`_ array of point coordinates
        :param `LineColor`: see :meth:`~lib.floatcanvas.FloatCanvas.DrawObject.SetColor`
        :param `LineStyle`: see :meth:`~lib.floatcanvas.FloatCanvas.DrawObject.SetLineStyle`
        :param `LineWidth`: see :meth:`~lib.floatcanvas.FloatCanvas.DrawObject.SetLineWidth`
        :param boolean `InForeground`: should object be in foreground

        """
        wx.lib.floatcanvas.FCObjects.DrawObject.__init__(self, InForeground)

        self.Points = Points
        self.CalcBoundingBox()

        self.LineColor = LineColor
        self.LineStyle = LineStyle
        self.LineWidth = LineWidth
        self.GuideMode = GuideMode

        self.SetPen(LineColor, LineStyle, LineWidth)

        self.HitLineWidth = max(LineWidth, self.MinHitLineWidth)

    def _Draw(self, dc , WorldToPixel, ScaleWorldToPixel, HTdc=None):
        # calculate guide extension and map guide to pixel boundaries
        psz = self._Canvas.PanelSize
        if self.GuideMode == wx.VERTICAL:
            self.Points[0][1] = self._Canvas.PixelToWorld((0, 0))[1]
            self.Points[1][1] = self._Canvas.PixelToWorld(psz)[1]
        else:
            self.Points[0][0] = self._Canvas.PixelToWorld((0, 0))[0]
            self.Points[1][0] = self._Canvas.PixelToWorld(psz)[0]

        Points = WorldToPixel(self.Points)
        dc.SetPen(self.Pen)
        dc.DrawLines(Points)
        if HTdc and self.HitAble:
            HTdc.SetPen(self.HitPen)
            HTdc.DrawLines(Points)

class ImagePanel(wx.Panel):
    def __init__(self, parent):
        wx.Panel.__init__(self, parent)
        # get a sizer
        vbsz = wx.BoxSizer(wx.VERTICAL)
        # add a canvas for image viewing
        self.canvas = wx.lib.floatcanvas.FloatCanvas.FloatCanvas(self,
            id = wx.ID_ANY, BackgroundColor = '#888888')
        vbsz.Add(self.canvas, 1, wx.EXPAND)
        self.SetSizer(vbsz)
        # define the zoom range
        self.zoomRange = [1/32, 1/23, 1/16, 1/11, 1/8, 2/11, 1/4, 1/3, 1/2, 2/3]
        self.zoomRange.append(1.0)
        self.zoomRange.extend([3/2, 2/1, 3/1, 4/1, 11/2, 8/1, 11/1, 16/1, 25/1,
                               32/1])
        self.canvas.Bind(wx.EVT_ENTER_WINDOW, self.startGuide)
        self.canvas.Bind(wx.EVT_LEAVE_WINDOW, self.stopGuide)        
        self.canvas.Bind(wx.lib.floatcanvas.FloatCanvas.EVT_MOUSEWHEEL,
                         self.onWheel)
        wx.CallAfter(self.imageView)
        self.gmode = 'Von'
        
    def imageView(self):
        # load the image
        img = wx.Image('ZAZ5829_j0258.jpg', wx.BITMAP_TYPE_ANY)
        self.iw = img.GetWidth()
        self.ih = img.GetHeight()
        # add image as a scaled bitmap to the canvas
##        self.scbmap = wx.lib.floatcanvas.FloatCanvas.ScaledBitmap(
        self.scbmap = ScBitmap(
            img, XY = (self.iw // -2, self.ih // 2), Height = self.ih,
            Position = 'tl')
        self.canvas.AddObject(self.scbmap)
        # go fit the image on the canvas
        self.imageFit()

    def imageFit(self):
        # get the actual canvas size
        cw, ch = self.canvas.PanelSize
        # find best zoom factor and zoom index to display the whole image
        self.zf = self.zoomRange[0]
        self.zi = 0
        for z in self.zoomRange[1:]:
            if (z * self.iw) > cw or (z * self.ih) > ch:
                break
            else:
                self.zf = z
                self.zi += 1
        # set the scale to fit whole image       
        self.canvas.Scale = self.zf
        self.canvas.SetToNewScale()
        
    def startGuide(self, event):
        if event.Entering:
            # get the current canvas w x h pixel size
            cps = self.canvas.PanelSize
            # convert to canvas w x h world size
            self.cws = self.canvas.PixelToWorld(cps)
            # get world coordinates of canvas origine (top-left)
            self.cwo = self.canvas.PixelToWorld((0, 0))
            # get the coordinate for a V- or H-guide
            sp = self.canvas.PixelToWorld(event.GetPosition())
            sp = (round(sp[0]), round(sp[1]))
            # draw the guide
            if self.gmode == 'Von':
                # draw a vertical guide line
                self.cg = Guide(N.array(((sp[0], self.cwo[1]),
                                          (sp[0], self.cws[1]))),
                                          GuideMode = wx.VERTICAL)
            elif self.gmode == 'Hon':
                # draw a horizontal guide line
                self.cg = Guide(N.array(((self.cwo[0], sp[1]),
                                          (self.cws[0], sp[1]))),
                                          GuideMode = wx.HORIZONTAL)
            else:
                print('event handled by showGuide without valid guide mode')
                return
            # put the guide on the canvas
            self.canvas.AddObject(self.cg)
            # keep the current position for later moving
            self.cp = sp
            self.canvas.Bind(wx.lib.floatcanvas.FloatCanvas.EVT_MOTION,
                         self.onMove)

    def stopGuide(self, event):
        if event.Leaving:
            # remove last drawn guide
            self.canvas.RemoveObject(self.cg)
            self.cg = None
            self.canvas.Draw()
            self.canvas.Unbind(wx.lib.floatcanvas.FloatCanvas.EVT_MOTION)

    def onMove(self, event):
        cx, cy = event.Coords
        # round to nearest integer for mapping to image pixel boudaries
        cx = round(cx)
        cy = round(cy)
        # update the current guide position with a delta
        if self.gmode == 'Von':
            self.cg.Points += (cx - self.cp[0], 0)
            self.cg.CalcBoundingBox()
        elif self.gmode == 'Hon':
            self.cg.Points += (0, cy - self.cp[1])
            self.cg.CalcBoundingBox()
        # move the guide on the canvas
        self.canvas.Draw(True)
        # update the current position
        self.cp = (cx, cy)
        event.Skip()
        
    def onWheel(self, event):
        if wx.GetKeyState(wx.WXK_CONTROL):
            pos = event.GetPosition()
            oldzf = self.zf
            if event.GetWheelRotation() < 0:
                if self.zi > 0:
                    self.zi -= 1
            else:
                if self.zi < len(self.zoomRange) - 1:
                    self.zi += 1
            self.zf = self.zoomRange[self.zi]
            self.canvas.Zoom(self.zf / oldzf, pos, 'Pixel',
                             keepPointInPlace = True)

class TopWindow(wx.Frame):
    def __init__(self, sz):
        wx.Frame.__init__(self, parent = None, id = wx.ID_ANY,
                          title = "Pixel zoom test", size = sz)
        self.panel = wx.Panel(self)
        self.fsizer = wx.BoxSizer(wx.VERTICAL)
        # add the image panel
        self.imP = ImagePanel(self.panel)
        self.fsizer.Add(self.imP, 1, wx.EXPAND|wx.ALL, 1)
        self.panel.SetSizerAndFit(self.fsizer)

if __name__ == '__main__':
    app = wx.App(False)
    w, h = wx.GetDisplaySize()
    frame = TopWindow((int(w*0.5), int(h*0.35)))
    frame.Show(True)
    app.MainLoop()

Any help to point me in the direction of a solution is appreciated.
I need to use ScaledBitmap2 because images can be large. The subclass is needed to avoid a fatal error when passing floats into the method to cut out a rectangle and also to get the correct scaling quality for viewing individual pixels for zoom levels > 1.0.
Thanks in advance.

Sorry, I forgot that my pixzoomtst.py application needs an image file in the same folder.
So, here it is:

To make the problem more clear, here is a picture of a zoomed image with a vertical guide that is not in line with the pixel boundaries.

Gimp

Yes, Gimp does it pixel perfect, I know. Setting guides is not the end goal of my application so when FloatCanvas could also reach that pixel perfect state, I can move on.
I think it is related to the shift that is done when zooming with ‘keepPointInPlace’.
Or is it the truncation to int that happens in ScaledBitmap2 when only part of the bitmap is on display.
If someone has a suggestion for a work around,I like to hear it.
Thanks.

well, has this got a somewhat useful description ? :flushed:
a first misconception is when zooming in & out your guide stays fixed but the image pixels move (have another look at Gimp)

The ultimate goal is adjustment of ship plans with transformations that map a set of source points to a set of destination points. If it really interests you, have a look here: https://modelshipworld.com/topic/30287-adjusting-aligning-scanned-ship-plans/?_fromLogout=1

Now I try to write a GUI for my command line program that does the transformations.
Starting from a ship plan, the first step is divide it into sheer, body and half-breadth plans.
Next is marking the required source points and the destination points.
Finally the transform is done and the result shown.
If Gimp can do all this I would be stupid not to use it.

@kats well, if I got it right you start off with a raster graphics (picture, ship plan of sort) seeing some lines, corner, edges out of alignment and guessing where they ought to be

or, generally, transforming a raster into a vector graphic (or near) and the most interesting point here is the transform

however, if you are happy with those pixel positions I had a look into Gimp’s Procedure Browser and by using ‘gimp-image-find-next-guide’ and ‘gimp-image-get-guide-position’ you may write a filter to get these values into Python (as extension of Gimp) :rofl:

Thanks for pointing me to the Gimp Procedure Browser, but I still prefer using wxpython. That is why I have worked a bit further on this problem and can report some progress. For some zoom factors it is even pixel perfect but for others there is still a small (1 or 2 pixels) deviation in the placement of the image on the canvas. You can take a look at the code to see the additions made in the _DrawSubBitmap method of ScaledBitmap2. It is mainly the addition of a corrective shift to XYs. Also some extra code to avoid sub-pixel gaps at the borders. Here you see the improved result:


And her the code:

#! python3
# -*- coding: utf-8 -*-

import wx
import wx.lib.floatcanvas.FloatCanvas
import wx.lib.floatcanvas.FCEvents
import wx.lib.floatcanvas.FCObjects
import numpy as N
from wx.lib.floatcanvas.Utilities import BBox
from pubsub import pub

def YDownProjection(CenterPoint):
    return N.array((1, -1), float)

class ScBitmap(wx.lib.floatcanvas.FCObjects.ScaledBitmap2):
    def __init__(self,
                 Bitmap,
                 XY,
                 Height,
                 Width = None,
                 Position = 'tl',
                 InForeground = False):
        wx.lib.floatcanvas.FCObjects.ScaledBitmap2.__init__(self, Bitmap,
                 XY, Height, Width, Position, InForeground)

    def CalcBoundingBox(self):
        """Calculate the bounding box."""
        w, h = self.Width, self.Height
        x, y = self.ShiftFun(self.XY[0], self.XY[1], w, h, world = 0)
        self.BoundingBox = BBox.asBBox(((x, y), (x + w, h - y)))

    def WorldToBitmap(self, Pw):
        """Computes the bitmap coords from World coords."""
        return (Pw - self.XY) * self.BmpScale

    def _DrawEntireBitmap(self, dc , WorldToPixel, ScaleWorldToPixel, HTdc):
        """
        this is pretty much the old code

        Scales and Draws the entire bitmap.

        """
        XY = WorldToPixel(self.XY)
        H = ScaleWorldToPixel(self.Height)[0]
        W = int(H * (self.bmpWidth / self.bmpHeight))
        if (self.ScaledBitmap is None) or (self.ScaledBitmap[0] != (0, 0, self.bmpWidth, self.bmpHeight, W, H) ):
        #if True: #fixme: (self.ScaledBitmap is None) or (H != self.ScaledHeight) :
            self.ScaledHeight = H
            #print("Scaling to:", W, H)
            if (W / self.bmpWidth) <= 1.0:
                Img = self.Image.Scale(W, H, quality=wx.IMAGE_QUALITY_HIGH) # show anti-aliased
            else:
                Img = self.Image.Scale(W, H, quality=wx.IMAGE_QUALITY_NORMAL) # show individual pixels
            bmp = wx.Bitmap(Img)
            self.ScaledBitmap = ((0, 0, self.bmpWidth, self.bmpHeight , W, H), bmp)# this defines the cached bitmap
        else:
            #print("Using Cached bitmap")
            bmp = self.ScaledBitmap[1]
        XY = self.ShiftFun(XY[0], XY[1], W, H)
        dc.DrawBitmap(bmp, XY, True)
        if HTdc and self.HitAble:
            HTdc.SetPen(self.HitPen)
            HTdc.SetBrush(self.HitBrush)
            HTdc.DrawRectangle(XY, (W, H) )

    def _DrawSubBitmap(self, dc , WorldToPixel, ScaleWorldToPixel, HTdc):
        """
        Subsets just the part of the bitmap that is visible
        then scales and draws that.

        """
        BBworld = BBox.asBBox(self._Canvas.ViewPortBB)
        BBbitmap = BBox.fromPoints(self.WorldToBitmap(BBworld))

        XYs = WorldToPixel(self.XY)
        # figure out subimage:
        # fixme: this should be able to be done more succinctly!

        Xc = Yc = 0.0 # setup for alignment correction

        if BBbitmap[0,0] < 0:
            Xb = 0
        elif BBbitmap[0,0] > self.bmpWH[0]: # off the bitmap
            Xb = 0
        else:
            Xb = int(BBbitmap[0,0] + 0.5)
            Xc = BBbitmap[0,0] - Xb # keep for alignment correction
            XYs[0] = 0 # draw at origin
            # check and correct for possible sub-pixel gap at the left
            if (Xc < 0.0) and (ScaleWorldToPixel(Xc)[0] < -0.5) and (Xb > 0):
                Xb = Xb - 1 # add an image pixel left
                Xc = Xc + 1.0 # increase shift

        if BBbitmap[0,1] < 0:
            Yb = 0
        elif BBbitmap[0,1] > self.bmpWH[1]: # off the bitmap
            Yb = 0
            ShouldDraw = False
        else:
            Yb = int(BBbitmap[0,1] + 0.5)
            Yc = BBbitmap[0,1] - Yb # keep for alignment correction
            XYs[1] = 0 # draw at origin
            # check and correct for possible sub-pixel gap at the top
            if (Yc < 0.0) and (ScaleWorldToPixel(Yc)[0] < -0.5) and (Yb > 0):
                Yb = Yb - 1 # add an image pixel at the top
                Yc = Yc + 1.0 # increase the shift for this extra image pixel
 
        if BBbitmap[1,0] < 0:
            #off the screen --  This should never happen!
            Wb = 0
        elif BBbitmap[1,0] > self.bmpWH[0]:
            Wb = self.bmpWH[0] - Xb
        else:
            Wb = int(BBbitmap[1,0] + 0.5) - Xb
        # avoid a possible sub-pixel pixel gap at the right
        if (Wb + Xb) < self.bmpWH[0]:
            Wb += 1 

        if BBbitmap[1,1] < 0:
            # off the screen --  This should never happen!
            Hb = 0
            ShouldDraw = False
        elif BBbitmap[1,1] > self.bmpWH[1]:
            Hb = self.bmpWH[1] - Yb
        else:
            Hb = int(BBbitmap[1,1] + 0.5) - Yb
        # avoid a possible sub-pixel pixel gap at the bottom
        if (Hb + Yb) < self.bmpWH[1]:
            Hb += 1

        FullHeight = ScaleWorldToPixel(self.Height)[0]
        scale = float(FullHeight) / float(self.bmpWH[1])
        Ws = int(scale * Wb + 0.5) # add the 0.5 to  round
        Hs = int(scale * Hb + 0.5)
        if (self.ScaledBitmap is None) or (self.ScaledBitmap[0] != (Xb, Yb, Wb, Hb, Ws, Ws)):
            Img = self.Image.GetSubImage(wx.Rect(Xb, Yb, Wb, Hb))
            #print("rescaling with High quality")
            if scale <= 1.0:
                Img.Rescale(Ws, Hs, quality=wx.IMAGE_QUALITY_HIGH)
            else:
                Img.Rescale(Ws, Hs, quality=wx.IMAGE_QUALITY_NORMAL)
            bmp = wx.Bitmap(Img)
            self.ScaledBitmap = ((Xb, Yb, Wb, Hb, Ws, Ws), bmp)# this defines the cached bitmap
            #XY = self.ShiftFun(XY[0], XY[1], W, H)
            #fixme: get the shiftfun working!
        else:
            #print("Using cached bitmap")
            ##fixme: The cached bitmap could be used if the one needed is the same scale, but
            ##       a subset of the cached one.
            bmp = self.ScaledBitmap[1]
        # correction for alignment to image pixel boundaries
        XYs = XYs - ScaleWorldToPixel((Xc, Yc))
        dc.DrawBitmap(bmp, XYs, True)

        if HTdc and self.HitAble:
            HTdc.SetPen(self.HitPen)
            HTdc.SetBrush(self.HitBrush)
            HTdc.DrawRectangle(XYs, (Ws, Hs) )

class Guide(wx.lib.floatcanvas.FCObjects.Line):
    """
    Draws a guide line vertical or horizontal.

    It will draw a straight vertical line at the x-coordinate 
    or a straight horizontal line at the y-coordinate.
    These lines will extend to the viewport boundaries.

    """
    def __init__(self, Points,
                 LineColor = "Blue",
                 LineStyle = "ShortDash",
                 LineWidth    = 1,
                 InForeground = True,
                 GuideMode = wx.VERTICAL):
        """
        Default class constructor.

        :param `Points`: takes a 2-tuple, or a (2,)
         `NumPy <http://www.numpy.org/>`_ array of point coordinates
        :param `LineColor`: see :meth:`~lib.floatcanvas.FloatCanvas.DrawObject.SetColor`
        :param `LineStyle`: see :meth:`~lib.floatcanvas.FloatCanvas.DrawObject.SetLineStyle`
        :param `LineWidth`: see :meth:`~lib.floatcanvas.FloatCanvas.DrawObject.SetLineWidth`
        :param boolean `InForeground`: should object be in foreground

        """
        wx.lib.floatcanvas.FCObjects.DrawObject.__init__(self, InForeground)

        self.Points = Points
        self.CalcBoundingBox()

        self.LineColor = LineColor
        self.LineStyle = LineStyle
        self.LineWidth = LineWidth
        self.GuideMode = GuideMode

        self.SetPen(LineColor, LineStyle, LineWidth)

        self.HitLineWidth = max(LineWidth, self.MinHitLineWidth)

    def CalcBoundingBox(self):
        self.BoundingBox = BBox.InfBBox() # to make it drawn always

    def _Draw(self, dc , WorldToPixel, ScaleWorldToPixel, HTdc=None):
        Points = WorldToPixel(self.Points)
        # extend guide to canvas boundaries
        if self.GuideMode == wx.VERTICAL:
            Points[0][1] = 0
            Points[1][1] = self._Canvas.PanelSize[1]
        else:
            Points[0][0] = 0
            Points[1][0] = self._Canvas.PanelSize[0]
        dc.SetPen(self.Pen)
        dc.DrawLines(Points)
        if HTdc and self.HitAble:
            HTdc.SetPen(self.HitPen)
            HTdc.DrawLines(Points)

class ImagePanel(wx.Panel):
    def __init__(self, parent):
        wx.Panel.__init__(self, parent)
        # get a sizer
        vbsz = wx.BoxSizer(wx.VERTICAL)
        # add a canvas for image viewing
        self.canvas = wx.lib.floatcanvas.FloatCanvas.FloatCanvas(self,
            id = wx.ID_ANY,
            ProjectionFun = YDownProjection,
            BackgroundColor = '#888888')
        vbsz.Add(self.canvas, 1, wx.EXPAND)
        self.SetSizer(vbsz)
        # define the zoom range
        self.zoomRange = [1/32, 1/23, 1/16, 1/11, 1/8, 2/11, 1/4, 1/3, 1/2, 2/3]
        self.zoomRange.append(1.0)
        self.zoomRange.extend([3/2, 2/1, 3/1, 4/1, 11/2, 8/1, 11/1, 16/1, 23/1,
                               32/1])
        self.canvas.Bind(wx.EVT_ENTER_WINDOW, self.startGuide)
        self.canvas.Bind(wx.EVT_LEAVE_WINDOW, self.stopGuide)        
        self.canvas.Bind(wx.lib.floatcanvas.FloatCanvas.EVT_MOUSEWHEEL,
                         self.onWheel)
        wx.CallAfter(self.imageView)
        self.gmode = wx.VERTICAL
        
    def imageView(self):
        # load the image
        img = wx.Image('ZAZ5829_j0258.jpg', wx.BITMAP_TYPE_ANY)
        self.iw = img.GetWidth()
        self.ih = img.GetHeight()
        # add image as a scaled bitmap to the canvas
        self.scbmap = ScBitmap(Bitmap = img, XY = (0, 0),
                               Height = self.ih, Position = 'tl')
        self.canvas.AddObject(self.scbmap)
        # set canvas ViewPortCenter at image top-left corner
        self.canvas.ViewPortCenter = N.array((self.iw / 2, self.ih / 2), float)
        # go fit the image on the canvas
        self.imageFit()

    def imageFit(self):
        # get the actual canvas size
        cw, ch = self.canvas.PanelSize
        # find best zoom factor and zoom index to display the whole image
        self.zf = self.zoomRange[0]
        self.zi = 0
        for z in self.zoomRange[1:]:
            if (z * self.iw) > cw or (z * self.ih) > ch:
                break
            else:
                self.zf = z
                self.zi += 1
        # set the scale to fit whole image       
        self.canvas.Scale = self.zf
        self.canvas.SetToNewScale()
        sc = round(100 * self.zf, 2)
        pub.sendMessage('status_update', stD=[(f'{sc} %', 4)])
        
    def startGuide(self, event):
        if event.Entering:
            # get the coordinate for a V- or H-guide
            sp = self.canvas.PixelToWorld(event.GetPosition())
            sp = (int(sp[0] + 0.5), int(sp[1] + 0.5))
            # draw the guide
            if self.gmode == wx.VERTICAL:
                # draw a vertical guide line
                self.cg = Guide(N.array(((sp[0], 0), (sp[0], 0)), float),
                                          GuideMode = wx.VERTICAL)
            elif self.gmode == wx.HORIZONTAL:
                # draw a horizontal guide line
                self.cg = Guide(N.array(((0, sp[1]), (0, sp[1])), float),
                                          GuideMode = wx.HORIZONTAL)
            else:
                print('event handled by showGuide without valid guide mode')
                return
            # put the guide on the canvas
            self.canvas.AddObject(self.cg)
            # keep the current position for later moving
            self.cp = sp
            self.canvas.Bind(wx.lib.floatcanvas.FloatCanvas.EVT_MOTION,
                         self.onMove)

    def stopGuide(self, event):
        if event.Leaving:
            # remove last drawn guide
            self.canvas.RemoveObject(self.cg)
            self.cg = None
            self.canvas.Draw()
            self.canvas.Unbind(wx.lib.floatcanvas.FloatCanvas.EVT_MOTION)

    def onMove(self, event):
        cx, cy = event.Coords
        cxr1 = int(cx * 10 + 0.5) / 10
        cyr1 = int(cy * 10 + 0.5) / 10
        pub.sendMessage('status_update', stD=[(f'{cxr1}, {cyr1}', 2)])
        # round to nearest integer for mapping to image pixel boudaries
        cx = int(cx + 0.5)
        cy = int(cy + 0.5)
        # update the current guide position with a delta
        if self.gmode == wx.VERTICAL:
            d = (cx - self.cp[0], 0)
        elif self.gmode == wx.HORIZONTAL:
            d = (0, cy - self.cp[1])
        # move the guide on the canvas
        self.cg.Move(d)
        self.canvas.Draw(True)
        # update the current position
        self.cp = (cx, cy)
        event.Skip()
        
    def onWheel(self, event):
        if wx.GetKeyState(wx.WXK_CONTROL):
            pos = event.GetPosition()
            oldzf = self.zf
            if event.GetWheelRotation() < 0:
                if self.zi > 0:
                    self.zi -= 1
            else:
                if self.zi < len(self.zoomRange) - 1:
                    self.zi += 1
            self.zf = self.zoomRange[self.zi]
            self.canvas.Zoom(self.zf / oldzf, pos, 'Pixel',
                             keepPointInPlace = True)
            sc = round(100 * self.zf, 2)
            pub.sendMessage('status_update', stD=([(f'{sc} %', 4)]))

class TopWindow(wx.Frame):
    def __init__(self, sz):
        wx.Frame.__init__(self, parent = None, id = wx.ID_ANY,
                          title = "Pixel zoom test", size = sz)
        # add a statusbar
        self.stb = self.CreateStatusBar(5)
        self.SetStatusWidths([10, 45, 80, 30, 80])
        stText = [('Position:', 1), ('Scale:', 3)]
        self.updateStatus(stText)
        # install listener for future status updates
        pub.subscribe(self.updateStatus, ('status_update'))

        self.panel = wx.Panel(self)
        self.fsizer = wx.BoxSizer(wx.VERTICAL)
        # add the image panel
        self.imP = ImagePanel(self.panel)
        self.fsizer.Add(self.imP, 1, wx.EXPAND|wx.ALL, 1)
        self.panel.SetSizerAndFit(self.fsizer)

    def updateStatus(self, stD):
        for sd in stD:
            self.stb.SetStatusText(sd[0], sd[1])
            
if __name__ == '__main__':
    app = wx.App(False)
    w, h = wx.GetDisplaySize()
    frame = TopWindow((int(w*0.5), int(h*0.35)))
    frame.Show(True)
    app.MainLoop()

And here you see an example of the small misalignment at some specific zoom factors.

Maybe the presented code can help other wxpython users.
If you think I tackle the problem in the wrong way, let me know.