pyqtgraph icon indicating copy to clipboard operation
pyqtgraph copied to clipboard

Should `ImageItem` take its opacity into consideration when saving?

Open ntjess opened this issue 4 years ago • 17 comments

import pyqtgraph as pg
import numpy as np
app = pg.mkQApp()
ii = pg.ImageItem()
ii.setOpacity(0)
ii.setImage(np.random.random((500,500,3)))
ii.save('./test.png')

I would argue this should be functionally equivalent to

ii.setImage(np.random.random((500,500,4)))
ii.image[...,3] = 0

Since it is visually equivalent.

But in the first example, the saved image is fully visible. Documentation indicates the visible image is saved, so I would think image opacity should be taken into account the same way. Currently I have to resort to a LUT with alpha proportional to image opacity before saving when it would be much easier to interface with setOpacity. Perhaps a separate argument could be provided to save?

ntjess avatar Feb 04 '21 21:02 ntjess

I don't use ImageItem, and use ImageView in minimal ways, but I think this is a good idea. I would totally accept a PR provided it didn't break existing functionality.

j9ac9k avatar Feb 04 '21 22:02 j9ac9k

how about using pillow?

import numpy as np
import pyqtgraph as pg
from PIL import Image

data = np.random.random((500, 500, 3))
ii = pg.ImageItem(data)
ii.render()
pimg = Image.fromqimage(ii.qimage)
pimg.putalpha(0)
pimg.save('test.png')

pijyoi avatar Feb 05 '21 06:02 pijyoi

I'm not sure how this would affect images with alpha channels and image opacity (does it stack or overwrite?)

Something within pyqtgraph that worked for me was

def newSave(ii: pg.ImageItem, fileName: str='./test.png', *args, includeBorder=False, 
            useOpacity=False):
  im = pg.fn.makeQImage(np.zeros_like(ii.image))
  painter = QtGui.QPainter(im)
  oldBorder = ii.border
  if not includeBorder:
    ii.setBorder(None)
  if useOpacity:
    painter.setOpacity(ii.opacity())
  ii.paint(painter)
  ii.setBorder(oldBorder)
  painter.end()
  im.save(fileName, *args)

This preserves any alpha from LUT as well as RGBA channels in the underlying image data, and only adds additional image opacity when painting the qimage.

@j9ac9k if you like, I can submit a PR that changes save to perform this action instead of directly calling render(). This has the added benefits of also adding the image border, which the old save method did not. The default flag values would preserve past behavior if that is of interest.

ntjess avatar Feb 05 '21 06:02 ntjess

@j9ac9k if you like, I can submit a PR that changes save to perform this action instead of directly calling render(). This has the added benefits of also adding the image border, which the old save method did not. The default flag values would preserve past behavior if that is of interest.

I'm good with this, but I'd likely follow recommendations/input from some of the other maintainers/contributors that have worked with the image capability of the library more; as I really use the bare minimum of it, and I know some of the Acq4 folks may have strong opinions.

@outofculture @dgoeries @pijyoi you all have input?

j9ac9k avatar Feb 05 '21 06:02 j9ac9k

@ntjess , have you tried your code on master? Quite a bit of changes due to #1466 and #1501

Also, ii.image can be any dtype and shape can be (h, w), (h, w, 1), (h, w, 3), (h, w, 4)

pijyoi avatar Feb 05 '21 08:02 pijyoi

@pijyoi revisions based on your comment:

import pyqtgraph as pg
import numpy as np
from pyqtgraph.Qt import QtGui, QtCore

class NewImageItem(pg.ImageItem):
  def save(self, fileName, *args, includeBorder=False, useOpacity=False):
    if self.image is None:
      # Warn? Raise error? Currently errs with AttributeError
      return
    pm = self.getPixmap()

    im = pm.toImage()
    im.fill(QtCore.Qt.transparent)
    painter = QtGui.QPainter(im)
    oldBorder = self.border
    if not includeBorder:
      self.setBorder(None)
    if useOpacity:
      painter.setOpacity(self.opacity())
    self.paint(painter)
    self.setBorder(oldBorder)
    painter.end()
    im.save(fileName, *args)

app = pg.mkQApp()
ii = NewImageItem()
ii.setBorder(pg.mkPen('r', width=5))
ii.setOpacity(0.7)
ii.setImage(np.random.random((500,500,3)))
ii.save('3chan.png', useOpacity=True, includeBorder=True)
ii.setImage(np.random.random((500,500,1)))
ii.save('1chan.png', useOpacity=True, includeBorder=True)
ii.setImage(np.random.random((500,500, 4)))
ii.save('4chan.png', useOpacity=True, includeBorder=True)
ii.setImage(np.random.random((500,500)))
ii.save('gray.png', useOpacity=True, includeBorder=True)

Since the function is being modified anyway, a few questions:

  • Is the current behavior when image is None desired, e.g. throwing an AttributeError on NoneType?
  • Would it be worthwhile to add toBytes and copy to the call signature as they exist in the ImageExporter?

ntjess avatar Feb 06 '21 21:02 ntjess

getPixmap() calls render() under the hood and uses the resulting self.qimage.

So the following code would have made a round-trip from QImage to Pixmap back to QImage, so why not just use self.qimage directly?

pm = QPixmap.fromImage(self.qimage)
im = pm.toImage()

In my opinion, the includeBorder option makes the ImageItem API non-orthogonal. Why allow the user to choose to have a saved image that is different from what is shown on screen?

It is easy to create a function outside of the library that serves one's particular need (e.g. one particular dtype) and that's what I do. But to make a general function, that's difficult because now you have to handle all corner cases.

pijyoi avatar Feb 07 '21 00:02 pijyoi

@pijyoi

getPixmap() calls render() under the hood and uses the resulting self.qimage.

So the following code would have made a round-trip from QImage to Pixmap back to QImage, so why not just use self.qimage directly?

pm = QPixmap.fromImage(self.qimage)
im = pm.toImage()

That sounds like a better way of doing things.

In my opinion, the includeBorder option makes the ImageItem API non-orthogonal. Why allow the user to choose to have a saved image that is different from what is shown on screen?

I wasn't sure if old behavior of save not including the border should be maintained if desired. Otherwise, I would agree it should be included without parameters. I would say the same thing for opacity, but for the same reason I didn't know if backwards behavior should be preserved somehow.

It is easy to create a function outside of the library that serves one's particular need (e.g. one particular dtype) and that's what I do. But to make a general function, that's difficult because now you have to handle all corner cases.

Sounds good, does this look better? Tests for I think the dtypes and shapes you mentioned

from pathlib import Path

import pyqtgraph as pg
import numpy as np
from pyqtgraph.Qt import QtGui, QtCore, QtWidgets
from skimage.data import chelsea
from skimage.util import *

pg.mkQApp()

class NewImageItem(pg.ImageItem):
  def save(self, fileName, *args):
    if self._renderRequired:
      self.render()
    # Don't make the pixmap if you don't have to :)
    im = QtGui.QImage(self.qimage)
    im.fill(QtCore.Qt.transparent)
    painter = QtGui.QPainter(im)
    oldBorder = self.border
    painter.setOpacity(self.opacity())
    self.paint(painter)
    self.setBorder(oldBorder)
    painter.end()
    ret = im.save(fileName, *args)
    assert ret

outs = Path('./images')
outs.mkdir(exist_ok=True)
app = pg.mkQApp()
ii = NewImageItem()
ii.setBorder(pg.mkPen('r', width=5))
data = chelsea()
data = np.dstack([data, np.full(data.shape[:2] + (1,), 255, dtype=data.dtype)])
converts = [img_as_float32, img_as_bool, img_as_int, img_as_uint, img_as_ubyte, img_as_float]
shapes = [(), (slice(0, 3),), (0,), (slice(0, 1),)]
for shape in shapes:
  for cvt in converts:
    curData = cvt(data)
    curSlice = (...,) + shape
    curData = curData[curSlice]
    ii.setOpacity(float(np.random.random(1)))
    ii.setImage(curData)
    ii.save(str(outs/f'{curData.shape} - {cvt.__name__} - opacity {ii.opacity()}.png'))

image

ntjess avatar Feb 07 '21 01:02 ntjess

Looking at the documentation, for opacity: https://doc.qt.io/qt-5/qgraphicsitem.html#setOpacity It seems that if you really want to save what appears on screen, you would need effectiveOpacity() instead.

So if we go back to your original premise:

ii = pg.ImageItem()
data = np.random.randint(256, size=(500,500,3), dtype=np.uint8)
ii.setImage(data)
ii.setOpacity(0.5)

is not functionally equivalent to:

ii = pg.ImageItem()
data = np.random.randint(256, size=(500,500,4), dtype=np.uint8)
ii.setImage(data)
data[..., 3] = 128

pijyoi avatar Feb 07 '21 10:02 pijyoi

Nice catch. It looks like a simple fix though, I just have to change the relevant line to painter.setOpacity(self.effectiveOpacity()), right? It seems to be working in my example suite.

I also removed the border lines since of course they were from when I was considering preserving old behavior. For completeness:


class NewImageItem(pg.ImageItem):
  def save(self, fileName, *args):
    if self._renderRequired:
      self.render()
    # Don't make the pixmap if you don't have to :)
    im = QtGui.QImage(self.qimage)
    # I can also run timing analysis to see if it's faster to use
    # im = QtGui.QImage(im.size(), im.format())
    im.fill(QtCore.Qt.transparent)
    painter = QtGui.QPainter(im)
    painter.setOpacity(self.effectiveOpacity())
    self.paint(painter)
    # Maybe "end" isn't necessary?
    painter.end()
    ret = im.save(fileName, *args)
    assert ret

ntjess avatar Feb 07 '21 14:02 ntjess

So the difference between ImageItem::save() and NewImageItem::save() is that the former saves the image that has passed through pyqtgraph library processing (downsampling, contrast stretching, colormapping, etc), whereas the latter wants to save what has further gone through Qt library painting processing.

That seems like two different plausible use-cases. i.e. the old behavior should be retained and the new behavior should only be enabled with a parameter, or even provided as a separate method entirely. @ntjess, wouldn't your needs be already addressed with your sub-classed class? Perhaps others would like to weigh in.

pijyoi avatar Feb 08 '21 01:02 pijyoi

@pijyoi As I understood it, save was designed to put a visual representation of the ImageItem to disk (or to bytes/clipboard if that is of interest). The current implementation of save does not do this faithfully: 1) the image border is not represented, and 2) any widget opacity is discarded. I would consider those bugs under the current description of save. The purpose of NewImageItem::save was to fulfill both missing objectives. If there is an easier way, that sounds great -- but this was the motivation for any changes I described.

ntjess avatar Feb 08 '21 04:02 ntjess

@pijyoi bumping the thread. I am fine with closing this issue if the current save behavior is considered complete, but I would appreciate some brief feedback on my previous response before then. Thanks!

ntjess avatar Feb 21 '21 04:02 ntjess

@ntjess given that you have already invested time modifying the code to fix what you perceive as a bug, you could raise a PR for a review.

I don't use save myself, but my view is that the existing behaviour, whether fulfilling its documented API or not, would have already become the de facto API.

https://www.hyrumslaw.com/

pijyoi avatar Feb 21 '21 06:02 pijyoi

FWIW I do agree that opacity should be included as part of the .save() functionality; I can also see an issue w/ changing default behavior as pijyoi points out. I would be good with having an includeOpacity named argument to ImageItem.save, which default is to False (to preserve current behavior), but we can put in the doc-strings that it will change to True in future versions...

j9ac9k avatar Feb 21 '21 18:02 j9ac9k

@j9ac9k Right. As you can see in my earlier comment I suggested the same thing for the image border, with False defaults for backwards compatibility. The reply was that save should represent what's on the screen. It doesn't matter to me either way, I'd just put in whatever is easier for a PR review.

ntjess avatar Feb 21 '21 18:02 ntjess

I somehow missed pijyoi's post:

So the difference between ImageItem::save() and NewImageItem::save() is that the former saves the image that has passed through pyqtgraph library processing (downsampling, contrast stretching, colormapping, etc), whereas the latter wants to save what has further gone through Qt library painting processing.

This is a compelling argument to preserving existing behavior, but I believe we should have the behavior you describe available; I don't think it should be a new method (save is pretty generic sounding) but should likely have a very detailed doc-string detailing what the existing behavior looks like (more so than what's currently there).

j9ac9k avatar Feb 21 '21 21:02 j9ac9k