wxWidgets / Phoenix

wxPython's Project Phoenix. A new implementation of wxPython, better, stronger, faster than he was before.
http://wxpython.org/
2.33k stars 516 forks source link

GraphicsContext does not use alpha channel properly when rendering fonts/text #1840

Open kdschlosser opened 4 years ago

kdschlosser commented 4 years ago

Operating system: MSW 7 x64 wxPython version & source: 4.1.0 msw (phoenix) wxWidgets 3.1.4, pypi Python version & source: 3.7.5 Stackless 3.7 (tags/v3.7.5-slp:f7925f2a02, Oct 20 2019, 15:28:53) [MSC v.1916 64 bit (AMD64)]

Description of the problem: when rendering text using a GraphicsContext instance the alpha channel causes a change in the brightness but not a change in opacity

Code Example (MSW only) ```python import ctypes.wintypes import ctypes import wx HANDLE = ctypes.wintypes.HANDLE LPWSTR = ctypes.wintypes.LPWSTR HRESULT = ctypes.HRESULT LONG = ctypes.wintypes.LONG HWND = ctypes.wintypes.HWND INT = ctypes.wintypes.INT HDC = ctypes.wintypes.HDC HGDIOBJ = ctypes.wintypes.HGDIOBJ BOOL = ctypes.wintypes.BOOL DWORD = ctypes.wintypes.DWORD UBYTE = ctypes.c_ubyte COLORREF = DWORD GWL_EXSTYLE = -20 WS_EX_LAYERED = 0x00080000 ULW_ALPHA = 0x00000002 AC_SRC_OVER = 0x00000000 AC_SRC_ALPHA = 0x00000001 def RGB(r, g, b): return COLORREF(r | (g << 8) | (b << 16)) class POINT(ctypes.Structure): _fields_ = [ ('x', LONG), ('y', LONG) ] class SIZE(ctypes.Structure): _fields_ = [ ('cx', LONG), ('cy', LONG) ] class BLENDFUNCTION(ctypes.Structure): _fields_ = [ ('BlendOp', UBYTE), ('BlendFlags', UBYTE), ('SourceConstantAlpha', UBYTE), ('AlphaFormat', UBYTE) ] byref = ctypes.byref kernel32 = ctypes.windll.Kernel32 GetTempPathW = kernel32.GetTempPathW GetTempPathW.restype = DWORD GetTempPathW.argtypes = [DWORD, LPWSTR] gdi32 = ctypes.windll.Gdi32 # HDC CreateCompatibleDC( # HDC hdc # ); CreateCompatibleDC = gdi32.CreateCompatibleDC CreateCompatibleDC.restype = HDC # HGDIOBJ SelectObject( # HDC hdc, # HGDIOBJ h # ); SelectObject = gdi32.SelectObject SelectObject.restype = HGDIOBJ # BOOL DeleteDC( # HDC hdc # ); DeleteDC = gdi32.DeleteDC DeleteDC.restype = BOOL shell32 = ctypes.windll.Shell32 SHGetFolderPathW = shell32.SHGetFolderPathW SHGetFolderPathW.restype = HRESULT SHGetFolderPathW.argtypes = [HWND, INT, HANDLE, DWORD, LPWSTR] user32 = ctypes.windll.User32 # LONG GetWindowLongW( # HWND hWnd, # int nIndex # ) GetWindowLongW = user32.GetWindowLongW GetWindowLongW.restype = LONG # LONG SetWindowLongW( # HWND hWnd, # int nIndex, # LONG dwNewLong # ); SetWindowLongW = user32.SetWindowLongW SetWindowLongW.restype = LONG # HDC GetDC( # HWND hWnd # ); GetDC = user32.GetDC GetDC.restype = HDC # HWND GetDesktopWindow(); GetDesktopWindow = user32.GetDesktopWindow GetDesktopWindow.restype = HWND # BOOL UpdateLayeredWindow( # HWND hWnd, # HDC hdcDst, # POINT *pptDst, # SIZE *psize, # HDC hdcSrc, # POINT *pptSrc, # COLORREF crKey, # BLENDFUNCTION *pblend, # DWORD dwFlags # ); UpdateLayeredWindow = user32.UpdateLayeredWindow UpdateLayeredWindow.restype = BOOL import math class AlphaFrame(wx.Frame): _xml = None _vehicle = None def __init__(self, parent=None, size=(800, 800), style=wx.TRANSPARENT_WINDOW): wx.Frame.__init__( self, parent, -1, style=( wx.NO_BORDER | wx.FRAME_NO_TASKBAR | style ) ) self.SetSize(size) self.Bind(wx.EVT_ERASE_BACKGROUND, lambda x: None) def Draw(self): width, height = self.GetClientSize() bmp = wx.Bitmap.FromRGBA(width, height) dc = wx.MemoryDC() dc.SelectObject(bmp) gc = wx.GraphicsContext.Create(dc) gc.SetPen(gc.CreatePen(wx.Pen(wx.Colour(255, 0, 0, 175), 10))) # gc.SetBrush(gc.CreateBrush(wx.Brush(wx.Colour(0, 0, 255, 100)))) gc.DrawRoundedRectangle(20, 20, width - 40, height / 2, 5) text = 'This is a transparent frame' text_len = len(text) text_width, char_height = self.GetFullTextExtent(text)[:2] radius = (min(width, height) * 0.90) / 2 circumference = radius * math.pi * 2 angle_range = 320.0 - 190.0 avg_char_width = text_width / text_len angle_ratio = angle_range / 360.0 arc_length = circumference * angle_ratio num_steps = arc_length / avg_char_width angle_spacing = angle_range / num_steps pixels_per_degree = circumference / 360.0 angle = 190.0 + (angle_spacing / 2) font = self.GetFont() font.SetStyle(wx.FONTSTYLE_ITALIC) font.MakeBold() font.SetFractionalPointSize(font.GetFractionalPointSize() * 3) gc.SetFont(font, wx.Colour(0, 0, 0, 1)) center_x = width / 2.0 center_y = height / 2.0 for char in list(text): char_width = gc.GetFullTextExtent(char)[0] angle_offset = (char_width / 2) / pixels_per_degree spacing = (angle_range / text_len) + angle_offset for _ in range(2): radians = math.radians(angle - angle_offset) cos = math.cos(radians) sin = math.sin(radians) x = center_x + (radius * cos) y = center_y + (radius * sin) text_radians = math.radians(angle - angle_offset + 90.0) gc.DrawText(char, x, y, -text_radians) angle_offset -= 0.3 angle += spacing angle = 190.0 + (angle_spacing / 2) gc.SetFont(font, wx.Colour(0, 255, 0, 200)) for char in list(text): char_width = gc.GetFullTextExtent(char)[0] angle_offset = (char_width / 2) / pixels_per_degree spacing = (angle_range / text_len) + angle_offset radians = math.radians(angle - angle_offset - 1) cos = math.cos(radians) sin = math.sin(radians) x = center_x + (radius * cos) y = center_y + (radius * sin) text_radians = math.radians(angle - angle_offset + 90.0) gc.DrawText(char, x, y, -text_radians) angle += spacing dc.SelectObject(wx.NullBitmap) gc.Destroy() del gc dc.Destroy() del dc self.Render(bmp) def Render(self, bmp, transparency=255): x, y = self.GetPosition() hndl = self.GetHandle() style = GetWindowLongW(HWND(hndl), INT(GWL_EXSTYLE)) SetWindowLongW(HWND(hndl), INT(GWL_EXSTYLE), LONG(style | WS_EX_LAYERED)) hdcDst = GetDC(GetDesktopWindow()) hdcSrc = CreateCompatibleDC(HDC(hdcDst)) pptDst = POINT(int(x), int(y)) psize = SIZE(bmp.GetWidth(), bmp.GetHeight()) pptSrc = POINT(0, 0) crKey = RGB(0, 0, 0) pblend = BLENDFUNCTION(AC_SRC_OVER, 0, transparency, AC_SRC_ALPHA) SelectObject(HDC(hdcSrc), HGDIOBJ(bmp.GetHandle())) UpdateLayeredWindow( HWND(hndl), HDC(hdcDst), byref(pptDst), byref(psize), HDC(hdcSrc), byref(pptSrc), crKey, byref(pblend), DWORD(ULW_ALPHA) ) DeleteDC(HDC(hdcDst)) DeleteDC(HDC(hdcSrc)) app = wx.App() frame = AlphaFrame() frame.Show() frame.Draw() app.MainLoop() ```
Metallicow commented 4 years ago

I'm not exactly sure why it is giving me errors about sending a wx.GraphicsFont. It appears you got something screwed up since it is accepting a wx.Font.

  1. I see that you are not using a PaintEvent.
  2. You are using FromRGBA might be causing issues.
  3. You are using a MemoryDC.

... so not exactly sure what the real problem is.

If you modify my TransparentPaintWindow Sample you can indeed see that the GraphicsFont alpha is working correctly.

transparentFontColour

I think somehow something got screwed up, since you are using your ctypes stuff...

Metallicow commented 4 years ago

Depending on the results you want, you can do like I did in the previous sample where I used your mswalpha for the transparent frame, and then create a 2nd float on parent frame for the mswalpha affected one and use a region for the text and then set the transparency on the frame. See this sample https://github.com/wxWidgets/Phoenix/issues/1544#issuecomment-661862752 That I think should allow you to use a PaintEvent and might work like you are expecting... Tho antialiasing may or may not work properly on the edges...

kdschlosser commented 4 years ago

You have to use FromRGBA otherwise the bitmap will not have an alpha channel... the MemoryDC is only being used for the purposes of selecting the bitmap. I am creating a GraphicsContext instance from the MemoryDC instance. This allows me to render to the bitmap using the Graphics context.

As you stated, The GraphicsContext.SetFont method should not be allowing a wxFont instance to be passed to it.

The only thing the ctypes stuff does is it draws the bitmap to the screen. That is it. The font is being rendered to the bitmap without an alpha channel and this is how it gets drawn on the screen by the ctypes stuff. The problem is NOT with that portion of the code.

There is a GraphicsContext.CreateFont method and I have tried using this as well but the results are exactly the same.

gc.SetFont(gc.CreateFont(font, wx.Colour(0, 255, 0, 100)))

I would have thought that this would utilize the alpha channel properly but it does not.

Metallicow commented 4 years ago

This old mailing list sample converted to phoenix might help.

Shaped Text Frame - Click to expand ```python import wx class ShapedText(wx.Frame): def __init__(self, text="Scrolling text!"): wx.Frame.__init__(self, None, style= wx.FRAME_SHAPED | wx.NO_BORDER | wx.FRAME_NO_TASKBAR | wx.STAY_ON_TOP) if not self.IsDoubleBuffered(): self.SetDoubleBuffered(True) # Reduce flicker. # Set up the timer which will move the text and paint the screen. self.timer = wx.Timer(self) self.Bind(wx.EVT_TIMER, self.OnTimer, source=self.timer) self.timer.Start(10) # Make sure we are using our custom paint handler. self.Bind(wx.EVT_PAINT, self.OnPaint) screenwidth = wx.SystemSettings.GetMetric(wx.SYS_SCREEN_X) screenheight = wx.SystemSettings.GetMetric(wx.SYS_SCREEN_Y) framewidth = int(screenheight * .75) frameheight = 100 framex = int(screenwidth/2 - framewidth/2) framey = screenheight - 150 # Create the bitmap. self.textFont = wx.Font(36, wx.FONTFAMILY_DEFAULT, wx.FONTSTYLE_NORMAL, wx.FONTWEIGHT_NORMAL) self.bmp = wx.Bitmap(framewidth, frameheight) self.SetScrolledString(text) self.SetWindowShape() self.SetPosition((framex, framey)) self.SetSize((framewidth, self.textHeight+5)) self.SetTransparent(100) def SetScrolledString(self, txt): self.scrolledStr = txt self.textOffset = 0 # Calculate the text width. memDC = wx.MemoryDC() memDC.SetFont(self.textFont) self.textWidth, self.textHeight, heightLine = memDC.GetFullMultiLineTextExtent(self.scrolledStr) memDC.Destroy() def SetWindowShape(self): # Use the bitmap's mask to determine the region. r = wx.Region(self.bmp) self.SetShape(r) def OnPaint(self, event): # Draw the bitmap on the screen. dc = wx.PaintDC(self) dc.DrawBitmap(self.bmp, 0, 0, useMask=True) def OnTimer(self, event): # Draw the text on the bitmap, set a mask, and paint. x, y = self.Size memDC = wx.MemoryDC(self.bmp) memDC.SetPen(wx.WHITE_PEN) memDC.DrawRectangle(0, 0, x, y) memDC.SetFont(self.textFont) memDC.DrawText(self.scrolledStr, x-self.textOffset, 0) memDC.Destroy() mask = wx.Mask(self.bmp, wx.WHITE) self.bmp.SetMask(mask) # Comment this line to disable transparency. self.textOffset += 1 # Stop the timer when the text has scrolled the entire way across. # print(self.textOffset-self.Size[0], self.textWidth) if self.textOffset - x == self.textWidth: # print(self.textOffset-self.Size[0], textWidth) self.timer.Stop() self.Destroy() self.SetWindowShape() self.Refresh() if __name__ == "__main__": app = wx.App() frame = ShapedText() frame.Show(True) app.MainLoop() ```
kdschlosser commented 4 years ago

nope that is not going to support alpha channels

RobinD42 commented 4 years ago

Some implementation details that may help, if you haven't already dug into the relevant C++ code: In the GDI+ GC backend the colour value is used to create a gdi+ SolidBrush, and that is then held by the wxGDIPlusFontData class along with the gdi+ Font object. The Font and Brush are then used when calling the gdi+ DrawString API. There really isn't a lot of complexity there, just a lot of little things working together to tie the two APIs together.

The alpha value is used when creating the brush, but I don't know why it wouldn't be using it. I do know that GDI+ has some holes in its functionality, perhaps drawing text with a partially transparent brush is one of them? Have you tried the Direct2D GC backend? If you're just interested in getting a bitmap out of this then using the Cairo backend would probably be an option as well.

kdschlosser commented 4 years ago

@RobinD42

I have also tried to wrap MemoryDC with GCDC and GraphicsContext with GCDC and the results are the same. Now.. If I create a GraphicsContext instance from a PaintDC it does render properly.

I am guessing I am probably going to have to learn more about Cairo

RobinD42 commented 4 years ago

The GCDC is still going to use the default renderer backend, which on Windows is GDI+, so I wouldn't expect any difference when using the GCDC vs what you were doing before. To use a non-default you need to explicitly create the renderer and then use it to create the GraphicsContext and related objects. For example, something like this:

        if 'wxMSW' in wx.PlatformInfo:
            renderer = wx.GraphicsRenderer.GetDirect2DRenderer() # or GetCairoRenderer
        else:
            renderer = wx.GraphicsRenderer.GetDefaultRenderer()
        ctx = renderer.CreateContext(dc)

If wanted, you can create the renderer once and reuse the same one throughout the application's lifetime.

There is also a module implementing a GraphicsContext-like in Python using Cairo, in wx.lib.Graphics. If you want to use the Cairo API directly then the wx.lib.wxcairo package provides some code to help you use Cairo on wx.DCs, wx.Bitmaps, etc.

kdschlosser commented 4 years ago

@RobinD42

If I use the Direct2D renderer as you have shown above. Nothing gets drawn.

srirams commented 2 years ago

I have the same issue (plus the text looks quite bad).

This is what it looks like (text is drawn on a WS_EX_LAYERED window, with a white window behind it) Capture2

To fix it, we have the use SetTextRenderingHint to TextRenderingHintAntiAliasGridFit. This fixes both the alpha channel not applying and the the text rendering. Capture

Unfortunately I couldn't find a way to call SetTextRenderingHint in wx, so had to use a .dll with

extern "C" __declspec(dllexport) int SetTextRenderingHint(void* v, int renderingHint) {
    Gdiplus::Graphics* g = reinterpret_cast<Gdiplus::Graphics*>(v);
    return g->SetTextRenderingHint(static_cast<Gdiplus::TextRenderingHint>(renderingHint));
}

and ctypes in python

        stdc = ctypes.cdll.LoadLibrary(R"PyWxGdiPlus.dll")
        TextRenderingHintAntiAliasGridFit = 3
        r = stdc.SetTextRenderingHint(ctypes.c_void_p(gc.GetNativeContext().__int__()), TextRenderingHintAntiAliasGridFit)

edit: and I don't believe this is a wx bug, same problem happens using gdiplus directly.

kdschlosser commented 2 years ago

edit: and I don't believe this is a wx bug, same problem happens using gdiplus directly.

This is good to know.

You should be able to make those function calls without having to make a dll.

something along these lines.


import ctypes

GpStatus = ctypes.HRESULT

gdiplus = ctypes.windll.gdiplus
GdipSetTextRenderingHint = gdiplus.GdipSetTextRenderingHint
GdipSetTextRenderingHint.restype = GpStatus

and then in a paint event


TextRenderingHintAntiAliasGridFit = 3

def OnPaint(self, event):
    # Create paint DC
    dc = wx.PaintDC(self)

    # Create graphics context from it
    gc = wx.GraphicsContext.Create(dc)
    if gc:
        GdipSetTextRenderingHint(ctypes.byref(gc.GetNativeContext()), TextRenderingHintAntiAliasGridFit)

something like that. I have not tested the above but it would be something really similar I would imagine.

If you look at the Windows SDK header file um\gdiplusgraphics.h and at line 199 is the method SetTextRenderingHint

Status SetTextRenderingHint(IN TextRenderingHint newMode)
    {
        return SetStatus(DllExports::GdipSetTextRenderingHint(nativeGraphics,
                                                          newMode));
    }

and all the method is doing is calling the exported GdipSetTextRenderingHint function. So it is a matter of passing the native context to the exported function.

If you look at um\gdiplusflat.h at line 1615 you have the following code.

GpStatus WINGDIPAPI
GdipSetTextRenderingHint(GpGraphics *graphics, TextRenderingHint mode);

In you code example you are getting the handle of the native context and creating the GpGraphics instance in c code using that handle then calling the method which in turn calls the exported function. Since there is already an instance of the native context that can be gotten on the python side of things there should be a way to pass that native context to the exported function directly without having to compile a dll to do it for us,

kdschlosser commented 2 years ago

code correction.

TextRenderingHintAntiAliasGridFit = 3

def OnPaint(self, event):
    # Create paint DC
    dc = wx.PaintDC(self)

    # Create graphics context from it
    gc = wx.GraphicsContext.Create(dc)
    if gc:
        GdipSetTextRenderingHint(gc.GetNativeContext(), TextRenderingHintAntiAliasGridFit)

I think that should work. GetNativeContext returns a pointer to GpGraphics I believe.

srirams commented 2 years ago

Thanks! Didn't realize there was a wrapper api for gdiplus.

I can't seem to get the parameters right for the call however. GetNativeContext() gives a <class 'sip.voidptr'>

so this should work: GdipSetTextRenderingHint(ctypes.c_void_p(gc.GetNativeContext().__int__()), TextRenderingHintAntiAliasGridFit)

however I'm getting a InvalidParameter result.

This: GdipSetTextRenderingHint(gc.GetNativeContext(), TextRenderingHintAntiAliasGridFit)

results in: [ctypes.ArgumentError: argument 1: <class 'TypeError'>: Don't know how to convert parameter 1]

kdschlosser commented 2 years ago

OK try wrapping the NativeContex in ctypes.byref()

kdschlosser commented 2 years ago

You can also try doing this


p = ctypes.c_void_p(gc.GetNativeContext().__int__())
 GdipSetTextRenderingHint(ctypes.byref(p), TextRenderingHintAntiAliasGridFit)
kdschlosser commented 2 years ago

I have another.

Here are 4 ways you can try it

p = gc.GetNativeContext()
 GdipSetTextRenderingHint(ctypes.byref(p), TextRenderingHintAntiAliasGridFit)
p = ctypes.c_void_p(gc.GetNativeContext().__int__())
 GdipSetTextRenderingHint(ctypes.byref(p), TextRenderingHintAntiAliasGridFit)
p = ctypes.cast(gc.GetNativeContext(), ctypes.POINTER(ctypes.c_void_p))
 GdipSetTextRenderingHint(p, TextRenderingHintAntiAliasGridFit)
p = ctypes.cast(gc.GetNativeContext(), ctypes.POINTER(ctypes.c_void_p))
 GdipSetTextRenderingHint(ctypes.byref(p), TextRenderingHintAntiAliasGridFit)
srirams commented 2 years ago

No, it doesn't work...

GdipSetTextRenderingHint expects a Gdiplus::GpGraphics and what we have is Gdiplus::Graphics, and I'm not sure how to convert between the two

kdschlosser commented 2 years ago

not sure then.