pystray icon indicating copy to clipboard operation
pystray copied to clipboard

PIL dependency simplification

Open Matteljay opened this issue 5 years ago • 11 comments

Hi great project i really appreciate any modules that add basic functions to Python while keeping it cross-platform. I'd like to "feature" request that you remove the dependency on PIL and allow for usage of an icon.png file. Hope i'm not alone in this desire, that it sounds reasonible and hope that it is easy to implement.

Matteljay avatar Nov 15 '18 13:11 Matteljay

+1 here. PIL increased my build size from somewhere around 23MB to around 150MB. That's clearly nonsense when no dynamic image creation is necessary.

I actually forked pystray and cut out PIL.

Currently, I’m building the system-specific image objects in my own code like this and hand them over to Icon():

if MAC:
	image = NSImage.alloc().initWithContentsOfFile_(NSBundle.mainBundle().pathForResource_ofType_('MacSystemTrayIcon', 'pdf'))
	image.setTemplate_(True)
if WIN:
	image = win32.LoadImage(
			None,
			os.path.join(os.path.dirname(__file__), 'icon', 'TaskbarIcon.ico'),
			win32.IMAGE_ICON,
			0,
			0,
			win32.LR_DEFAULTSIZE | win32.LR_LOADFROMFILE)

icon = pystray.Icon("Type.World", image, "Type.World", menu)

Doing this in user code has some advantages as the image.setTemplate_(True) shows. Achieving the same functionality in pystray further complicates its code. Otherwise I'm all for simplicity of use, of course.

I don't want to contribute my code because I've really messed with pystray here. But it works for now. And I've only implemented this for Windows and Mac, so my code is of no much use here.

yanone avatar Nov 25 '18 11:11 yanone

Thank you for your comments and request!

I understand your request to get rid of the PIL dependency---125 MB of seemingly unnecessary dependencies is somewhat unreasonable.

The reason for the dependency is that PIL appears to be the de-facto image library for Python, and a design goal of pystray is to abstract away the need for code like the example above with several if statements in every application using the library. As you note, the code above does not support Linux, in which case you would still need PIL.

Nonetheless, I think it would be a good idea to make the dependency on PIL optional for platforms not needing it, and add support for initialising the icon with a native handle.

moses-palmer avatar Nov 25 '18 18:11 moses-palmer

As a hack, If you only need to support windows and linux, then all the image needs to do is quack like one method of PIL.Image. On windows and linux the only method called is PIL.Image.save() so I made a pure python version of the PIL.Image class that only supports loading 256x256 alpha transparent PNGs and then saves to PNG / ICO by using the pure python code from https://github.com/flexxui/webruntime/blob/e88f7abb28fa0ea02c3f5cb4fb6b5fe258d7ed9c/webruntime/util/icon.py

A better API would be to actually just define the interface that pystray needs from the Image-like object, and call that.

nzjrs avatar Mar 01 '19 15:03 nzjrs

The original reason for the dependency on PIL is actually the X.Org backend; it has a direct dependency on PIL in the form of put_pil_image.

Now that I actually looked at the source, it does not seem very difficult to implement, but still: given the multitude of platforms supported by this library, the interface to image-like objects needed would need consideration, and the amount of code that needed to be written would be non-trivial.

moses-palmer avatar Mar 04 '19 14:03 moses-palmer

I'm on Windows, and I found it quite stupid to absolutely require PIL dependency. I get that it's required for a different backend, but I'm not aiming at deploying there, yet the Icon signature explicitly asks for a PIL.Image instance. I dug into the code and found out that all it does is call the save method into a temporary file, so I wrote this "wrapper" that simulates it:

class ICOImage:
    def __init__(self, path: str):
        with open(path, 'rb') as file:
            self._data = file.read()

    def save(self, file, format):
        file.write(self._data)

Now instead of Image.open("app.ico") in the signature, I can use ICOImage("app.ico") and it still works properly. Why one can't just pass in the icon file as bytes or a path to read directly from instead? It'd simplify things enormously.

My app package has grown from 10.2 MB to 48 MB after adding a tray icon with a PIL import, replacing the import with the wrapper above has shrunk it back down to 10.6 MB. I really think this part could be improved somewhat.

DevilXD avatar Jan 12 '22 20:01 DevilXD

I'm on Windows, and I found it quite stupid to absolutely require PIL dependency. I get that it's required for a different backend, but I'm not aiming at deploying there, yet the Icon signature explicitly asks for a PIL.Image instance. I dug into the code and found out that all it does is call the save method into a temporary file, so I wrote this "wrapper" that simulates it:

class ICOImage:
    def __init__(self, path: str):
        with open(path, 'rb') as file:
            self._data = file.read()

    def save(self, file, format):
        file.write(self._data)

Now instead of Image.open("app.ico") in the signature, I can use ICOImage("app.ico") and it still works properly. Why one can't just pass in the icon file as bytes or a path to read directly from instead? It'd simplify things enormously.

My app package has grown from 10.2 MB to 48 MB after adding a tray icon with a PIL import, replacing the import with the wrapper above has shrunk it back down to 10.6 MB. I really think this part could be improved somewhat.

Would this work on mac and linux as well?

ReenigneArcher avatar Mar 28 '22 00:03 ReenigneArcher

Would this work on mac and linux as well?

No, this is a Windows-only solution. The reason why an open PIL image is needed is because different operating systems require different solutions when it comes to the icon, most notably: Windows uses an ICO file, Linux and Mac (appear to) use PNG, Darwin uses what the code refers to as "NSImage", and so on. The PIL image is used to dynamically create all those formats on an as-needed basis depending on where the code is ran, hence why it doesn't really matter what format is the picture you're initially opening to serve as the icon. For my "workaround", it needs to explicitly be an ICO file already, and it'll work only for Windows, since that's the only place where ICO files are used. Still, if Windows is the only target platform, this removes the PIL dependency from the equation.

It turns out that I needed to use more PIL in my application anyway, so I reverted back to the documented way of providing the image.

DevilXD avatar Mar 28 '22 07:03 DevilXD

Would this work on mac and linux as well?

No, this is a Windows-only solution. The reason why an open PIL image is needed is because different operating systems require different solutions when it comes to the icon, most notably: Windows uses an ICO file, Linux and Mac (appear to) use PNG, Darwin uses what the code refers to as "NSImage", and so on. The PIL image is used to dynamically create all those formats on an as-needed basis depending on where the code is ran, hence why it doesn't really matter what format is the picture you're initially opening to serve as the icon. For my "workaround", it needs to explicitly be an ICO file already, and it'll work only for Windows, since that's the only place where ICO files are used. Still, if Windows is the only target platform, this removes the PIL dependency from the equation.

It turns out that I needed to use more PIL in my application anyway, so I reverted back to the documented way of providing the image.

Interesting. There should probably be an option to provide the exact image required instead of requiring Pillow. I understand that Pillow would be useful when there is a need to dynamically create an icon, but I would expect that the majority of use cases are just using an application logo/icon.

ReenigneArcher avatar Mar 28 '22 14:03 ReenigneArcher

For those interested, this is the code I use to remove the PIL dependency on windows/mac/linux

note: in my app I use opencv, so it makes sense to me to use that for the basic uses here, instead of PIL. your tradeoffs may be different


# Little endian int encoding (for bmp/icon writing)
w1 = lambda x: struct.pack('<B', x)
w2 = lambda x: struct.pack('<H', x)
w4 = lambda x: struct.pack('<I', x)

# quack like a PIL.Image without depending on PIL
class _Img(object):
    def __init__(self, path):
        self._arr = cv2.imread(path, cv2.IMREAD_UNCHANGED)
        if self._arr.shape[2] != 4:
            raise ValueError('must be PNG with alpha channel')

    @property
    def img(self):
        return self._arr[:, :, :3]

    def save(self, fd, format):
        if format == 'ICO':
            self._save_ico(fd)
        elif format in ('PNG', 'JPG'):
            _, arr = cv2.imencode('.%s' % format.lower(), self._arr)
            arr.tofile(fd)
        else:
            raise NotImplementedError

    @staticmethod
    def _to_bmp(im, file_header=False):
        _h, width = im.shape[:2]
        if _h != width:
            raise ValueError('must be square')
        height = reported_height = width
        if not file_header:
            reported_height *= 2  # This is soo weird, but it needs to be so

        # Flip vertically
        im = cv2.flip(im, 0)

        # DIB header
        bb = b''
        bb += w4(40)  # header size
        bb += w4(width)
        bb += w4(reported_height)
        bb += w2(1)  # 1 color plane
        bb += w2(32)
        bb += w4(0)  # no compression
        bb += w4(len(im))
        bb += w4(2835) + w4(2835)  # 2835 pixels/meter, ~ 72 dpi
        bb += w4(0)  # number of colors in palette
        bb += w4(0)  # number of important colors (0->all)

        # File header (not when bm is in-memory)
        header = b''
        if file_header:
            header += b'BM'
            header += w4(14 + 40 + len(im))  # file size
            header += b'\x00\x00\x00\x00'
            header += w4(14 + 40)  # pixel data offset

        # Add pixels
        # No padding, because we assume power of 2 image sizes
        return header + bb + im.tobytes()

    def _save_ico(self, fd):

        sizes = [(16, 16), (24, 24), (32, 32), (48, 48), (64, 64), (128, 128), (256, 256)]

        bb = b''
        imdatas = []

        # Header
        bb += w2(0)
        bb += w2(1)  # 1:ICO, 2:CUR
        bb += w2(len(sizes))

        # Put offset right after the last directory entry
        offset = len(bb) + 16 * len(sizes)

        # Directory (header for each image)
        for sw, sh in sizes:
            # icons are square
            size = sw

            im = cv2.resize(self._arr, (sw, sh))
            if size > 256:
                continue
            elif size >= 64:
                _, arr = cv2.imencode('.png', self._arr)
                imdata = arr.tobytes()
            else:
                imdata = self._to_bmp(im)

            imdatas.append(imdata)
            # Prepare dimensions
            w = h = 0 if size == 256 else size
            # Write directory entry
            bb += w1(w)
            bb += w1(h)
            bb += w1(0)  # number of colors in palette, assume no palette (0)
            bb += w1(0)  # reserved (must be 0)
            bb += w2(0)  # color planes
            bb += w2(32)  # bits per pixel
            bb += w4(len(imdata))  # size of image data
            bb += w4(offset)
            # Set offset pointer
            offset += len(imdata)

        fd.write(b''.join([bb] + imdatas))

and then I call it, more or less, like the following pseudocode

image = _Img('logo.png'))
menu = [pystray.MenuItem('Open XXX', self._on_open, default=True)]
icon = pystray.Icon("MyApp", image, "MyApp", menu)

nzjrs avatar Mar 28 '22 14:03 nzjrs

BTW, in case it wasn't clear from my code above, if you want to remove the opencv dependency then all you need is to ship PNG files of the correct size (which removes the need to resize them), and use the pure python PNG read/write routines from the file linked in https://github.com/moses-palmer/pystray/issues/26#issuecomment-468704074

As I said, I already depened on opencv so I didnt need them

nzjrs avatar Mar 31 '22 09:03 nzjrs

Has there been any movement on making PIL an optional dependency? Right now I also will have to fork this package in order to remove the PIL dependency. Easy, but not ideal.

whatamithinking avatar Jan 18 '23 22:01 whatamithinking