pygfx / wgpu-py

WebGPU for Python
https://wgpu-py.readthedocs.io
BSD 2-Clause "Simplified" License
464 stars 36 forks source link

Qt screenshots and scaling #510

Open hmaarrfk opened 5 months ago

hmaarrfk commented 5 months ago

On our qt application, we would like to take a "screenshot" of the window.

However, wgpu seems to draw directly onto the surface and bypasses qt's paintEvent so qt can't do it all on its own.

I've modified the triangle_qt_embed.py demo to allow it to fire off a request for a screenshot using Qt's grab() method. https://github.com/hmaarrfk/wgpu-py/pull/1

(PS. I'll continue this draft of an issue in a bit, I'm going to try to provide an example of working code to "pull a screenshot" forcifully from the renderer's internal buffer)

Korijn commented 5 months ago

You might want to compare your approach to solutions for Qt with pyopengl as well

hmaarrfk commented 5 months ago

Hmm. Great tip!

Truthfully I had a much harder translating my pygfx demo into a wgpu demo......

For the onlookers browsing. The symptom are that the widget on which the rendering is happening in wgpu is just blank.

My "fix" is to grab the rendered texture on the GPU and then rescale it myself prior to pasting it into the rest of the buffer provided by Qt.

It's pretty tricky because on pygfx at least, the internal texture can be of a different size than the Qt widget. So you have to make sure to resize things correctly (at least in my strategy)

almarklein commented 5 months ago

The best solution would be to take the GPU screenshot from the canvas' frame buffer. However, that texture has a lifetime that is bound to a "draw event". Or maybe it lives until the next draw event? I'd have to try/check.

Another possible route might be to have a public method to draw (I think this came up somewhere else to), and perhaps such a method could provide a custom texture. Basically a method to take a screenshot (to a texture) of any size you want.

hmaarrfk commented 5 months ago

I think this came up somewhere else to

I saw this issue today, https://github.com/pygfx/pygfx/issues/754 so it encouraged me to post my qt issue here to give an other perspective.

I updated my example above to generate: image

The red and blue rectangles are there to give you a sense that if we can somehow get the texture (correctly scaled for qt ....) then we can place it in the right position ourselves manually as a workaround. Of course, the best way would be to play nice with Qt, but sometimes thats just hard....

hmaarrfk commented 5 months ago

In pygfx, I have access to renderer.snapshot() which makes it possible to get the right data in there. I just don't know how to do it (yet) with wgpu.

panxinmiao commented 5 months ago

The usage flag of the surface texture (obtained from the context.get_current_texture() method) does not include COPY_SRC, so the texture data cannot be read directly from it.

If you need to capture a screenshot of the rendered scene, you should create a render target texture yourself with the usage flag set to COPY_SRC | RENDER_ATTACHMENTS and use it as the color attachment in the render pass. Then, use command_encoder.copy_texture_to_texture() to copy the data to the surface texture. When you call your screenshot() method, you can read the data from the target texture you created.

For QT applications, if you want to capture the entire GUI interface, not just the rendered scene, the best approach is to customize a QT widget by overriding its paintEvent method. Offscreen rendering with wgpu-py can easily assist you in achieving this. Here is a sample code snippet (modified from triangle_qt_embed.py):

import importlib

# For the sake of making this example Just Work, we try multiple QT libs
for lib in ("PySide6", "PyQt6", "PySide2", "PyQt5"):
    try:
        QtWidgets = importlib.import_module(".QtWidgets", lib)
        break
    except ModuleNotFoundError:
        pass

from wgpu.gui.offscreen import WgpuCanvas

from triangle import main

from PySide6.QtWidgets import QWidget
from PySide6.QtGui import QPainter, QImage

class RenderableCanvas(QWidget):
    def __init__(self, parent=None):
        super().__init__(parent)
        self._canvas = WgpuCanvas()

    def paintEvent(self, event):
        main(self._canvas) # do the animation and render logic
        frame = self._canvas.draw()
        painter = QPainter(self)
        width, height = frame.shape[1], frame.shape[0]
        img = QImage(frame, width, height, 4 * width,  QImage.Format_RGBA8888)
        painter.drawImage(event.rect(), img)

    def resizeEvent(self, event):
        self._canvas.set_logical_size(event.size().width(), event.size().height())

class ExampleWidget(QtWidgets.QWidget):
    def __init__(self):
        super().__init__()
        self.resize(640, 480)
        self.setWindowTitle("wgpu triangle embedded in a qt app")

        splitter = QtWidgets.QSplitter()

        self.button = QtWidgets.QPushButton("screnshot", self)
        self.button.clicked.connect(self.screenshot)
        self.canvas1 = RenderableCanvas(self)
        self.canvas2 = RenderableCanvas(self)

        splitter.addWidget(self.canvas1)
        splitter.addWidget(self.canvas2)

        layout = QtWidgets.QHBoxLayout()
        layout.addWidget(self.button, 0)
        layout.addWidget(splitter, 1)
        self.setLayout(layout)

        self.show()

    def screenshot(self):
        self.grab().save("screenshot.png")

app = QtWidgets.QApplication([])
example = ExampleWidget()

# Enter Qt event loop (compatible with qt5/qt6)
app.exec() if hasattr(app, "exec") else app.exec_()
hmaarrfk commented 5 months ago

Thank you for the detailed response, it will take me time to digest it.

almarklein commented 1 month ago

Since #586 you can instantiate a Qt Canvas with QWgpuWidget(present_method="image"), which should make it possible to make screenshots via Qt that include the rendered content.

You'll need to specify this when the widget is being created. At the time I looked into being able to change the method at runtime, but this complicated the logic in the GpuCanvasContext a lot. Since the "screen" method incurs a performance penalty, I'd recommend only turning it on when you want to create screenshots.

hmaarrfk commented 1 month ago

Interesting, i didn't expect you to be so active on this repo. I'll have to watch for all notifications here too!

hmaarrfk commented 1 month ago

thanks for the warning about the performance penalty. i don't really think we can afford it. we run most of our stuff on Intel Integrated....

almarklein commented 1 month ago

thanks for the warning about the performance penalty. i don't really think we can afford it. we run most of our stuff on Intel Integrated....

If you want to make screenshot for say a presentation, then you can run your app with present_method="image", take your shots, and turn it off again. But if you want to be able to take a screenshot at any moment in the normal workflow, yeah, we'd need something else. I'll leave the issue open to cover that case.

almarklein commented 1 month ago

Correction: above I mentioned QWgpuWidget(present_method="screen"), but the arg should be "image"!

almarklein commented 4 days ago

I was working on stuff that touches on this. In order for canvas content to show up on Qt's screenshots (QWidget.grab()), without compromising performance, we should set present_method='image' temporarily. Making this work is technically possible, but makes the code related to presenting quite a bit more complex. I think I consider this problem out of scope for wgpu/rendercanvas.

I think this problem must be solved another way, by taking a screenshot of the application from the framebuffer. I found a tiny pure-Python, cross-platform, zero-deps, library that does this: mss. It's API is a bit quirky, but it even has a tiny pure-Python png exporter!

For Qt:

import mss

def screenshot(toplevelwidget, filename):
    g = toplevelwidget.geometry()
    rect = {"top": g.top(), "left": g.left(), "width": g.width(), "height": g.height()}
    with mss.mss() as sct:
        screenshot = sct.grab(rect)
    mss.tools.to_png(screenshot.rgb, screenshot.size, output=filename)