Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Convert a C or numpy array to a Tkinter PhotoImage with a minimum number of copies

I know a recipe for displaying an MxNx3 numpy array as an RGB image via Tkinter, but my recipe makes several copies of the array in the process:

a = np.random.randint(low=255, size=(100, 100, 3), dtype=np.uint8) # Original
ppm_header = b'P6\n%i %i\n255\n'%(a.shape[0], a.shape[1])
a_bytes = a.tobytes() # First copy
ppm_bytes = ppm_header + a_bytes # Second copy https://en.wikipedia.org/wiki/Netpbm_format
root = tk.Tk()
img = tk.PhotoImage(data=ppm_bytes) # Third and fourth copies?
canvas = tk.Canvas(root, width=a.shape[0], height=a.shape[1])
canvas.pack()
canvas.create_image(0, 0, anchor=tk.NW, image=img) # Fifth copy?
root.mainloop()

How can I achieve an equivalent result with a minimum number of copies?

Ideally, I would create a numpy array which was a view of the same bytes that the Tkinter PhotoImage object was using, effectively giving me a PhotoImage with mutable pixel values, and making it cheap and fast to update the Tkinter display. I don't know how to extract this pointer from Tkinter.

Perhaps there's a way via ctypes, as hinted at here?

The PhotoImage.put() method seems very slow, but maybe I'm wrong, and that's a path forward?

I tried making a bytearray() containing the ppm header and the image pixel values, and then using numpy.frombuffer() to view the image pixel values as a numpy array, but I think the PhotoImage constructor wants a bytes() object, not a bytearray() object, and also I think Tkinter copies the bytes of its data input into its internal format (32-bit RGBA?). I guess this saves me one copy compared to the recipe above?

like image 581
Andrew Avatar asked Sep 22 '18 17:09

Andrew


2 Answers

  • you can eliminate the 1st and 2nd copies

You get a numpy.ndarray over arbitrary data with numpy.frombuffer:

shape=(100,100,3)
ppm_header = b'P6\n%i %i\n255\n'%(shape[0], shape[1])
ppm_bytes = ppm_header + b'\0'*(shape[0]*shape[1]*shape[2])
array_image = np.frombuffer(ppm_bytes, dtype=np.uint8, offset=len(ppm_header)).reshape(shape)
  • the 3rd and 4th copies are inevitable (see below), but the 3rd one is discarded right after the call

  • the 5th copy is not actually made (also see below)

  • the drawing stage involves a copy to the screen via the windowing system's drawing API which is also inevitable.


Tcl is a safe, garbage-collected language like Python, and Tcl objects don't support either a "buffer protocol", or using memory for their data that they don't own (though objects can be shared.

  • img = tk.PhotoImage(data=ppm_bytes) # Third and fourth copies?
    

    When making most Tcl calls, Python variables are first converted into Equivalent Tcl objects (first into C values which doesn't involve copying for a bytestring which are then passed to Tcl constructors which does involve a copy), then these objects are passed to Tcl_EvalObjv.

    On the Tcl side, photo (which PhotoImage() wraps) also parses input data (-data string argument) and as such, also cannot reuse its memory block. Even if it's a raw bitmap 'cuz Tcl strings have no "view" functionality.

    So, for a bytestring, there's one mandatory copy of the bitmap data involved at Pythob-to-Tcl call stage, and one more at image construction stage. (On the bright side, one copy (the argument string for the Tcl call) is discarded after the call.)

  • canvas.create_image(0, 0, anchor=tk.NW, image=img) # Fifth copy?
    

    A copy is not involved here, the canvas just saves a reference to the image object in its composition data.

    Now, canvas create image requires a Tcl image identifier as image argument; so there's no way around PhotoImage(), either.

  • (drawing stage)

    xlib's commands to draw a bitmap are used, no additional copies are involved.

    The thing to note here is that a canvas doesn't even have direct access to the resulting screen pixels. Instead, xlib uses backend's graphical API for drawing (like WinGDI's GetDC(), BitBlt() etc that you might be familiar with). E.g. in Windows, xlib's XCopyArea uses BitBlt.

like image 170
ivan_pozdeev Avatar answered Oct 13 '22 23:10

ivan_pozdeev


I can reduce it to 1 (maybe 2) copies by using PIL and a Label:

import numpy as np
import tkinter as tk
from PIL import Image, ImageTk

a = np.random.randint(low=255, size=(100, 100, 3), dtype=np.uint8) # Original
root = tk.Tk()
img = ImageTk.PhotoImage(Image.fromarray(a)) # First and maybe second copy.
lbl = tk.Label(root, image=img)
lbl.pack()
root.mainloop()

However that's still not mutable. If you want that I think you need to reinvent an image by placing a pixel on the canvas yourself. I did that once with this project and found that the fastest update was a matplotlib animation, which works really well for you since you are already using np arrays.

My code for using a tk.Canvas, a PIL Image(using putpixel()), and matplotlib.

like image 39
Novel Avatar answered Oct 13 '22 22:10

Novel