2025年8月4日 星期一

Python使用ctypes調用Windows API在64位元執行GDI+

上一篇把一些陳年問題解決了
就接著玩新的東西,該讓Windows API在Python上顯示圖片了
以前學Windows API時,原本的GDI只能讀BMP檔
但事隔多年,小畫家都支援多種格式了
微軟肯定有新的函式庫可以用
用Google找了一下,很快就找到了GDI+
GDI+支援jpg、png、gif、tiff等多種常用格式
那就是改使用它來提供顯示了

不過學習使用GDI+跟我想像中的不一樣
因為上一篇學到的東西,沒什麼派上用場的地方
而且要不是有Grok與ChatGPT等越來越好用的AI助陣
我真的寫不出來
因為微軟提供的C++範例把GDI+實作的函式庫包起來了
可以用Python呼叫的函式,沒有在範例裡顯示
算是藏在更深層的文件中
只靠我有限的時間與精力要爬到那些東西,不知道要花多久啊
還好有AI,那麼速速進入正題



先來看一下微軟官方給了範例碼好了,就這個「載入和顯示點陣圖
裡面這邊寫的很簡單

//C++範例程式碼
Image image(L"Grapes.jpg");
graphics.DrawImage(&image, 60, 10);

就這樣兩行...
可是實際上Python要寫的至少是這些

graphics = c_void_p()
GdipCreateFromHDC(hdc, byref(graphics))
image = POINTER(GpImage)()
GdipLoadImageFromFile("Grapes.jpg", byref(image))
GdipDrawImageI(graphics, image, 60, 10)

更不用說還有些初始化的要求沒寫到
而且上面還是Grok與ChatGPT寫給我的
我用Google去找這些函數,也只找到參考文件,沒找到什麼範例程式碼
這次沒AI真是寫不出來了

廢話不多說,直接從頭到尾介紹在Python裡GDI+要運作需要哪些新東西與整個流程
再把可執行的整個範例程式碼放在後面
一些問題處理過程與心中碎碎念就跳過吧
首先我們要建立幾個需要的結構體(Structure)
2個屬於GDI+,1個是傳統的win32api用的資料
屬於GDI+的要用GdiplusStartupInput,還有GpImage
而屬於傳統Windows API的就是PAINTSTRUCT,使用變數常用ps
這東西對之前有碰Windows API畫圖的,就很熟了

那麼還要定義GDI+實際使用的函數,裡面的引數與回傳值
不過這邊就交給後面範例程式碼顯示了
接下來就是實際寫出顯示的東西了
首先要先初始化GDI+

#建立空token,屬性ulong
gdiplus_token = c_ulong()
#建立GDI+啟動的輸入結構體,版本1,其它不使用
startup_input = GdiplusStartupInput()
startup_input.GdiplusVersion = 1
startup_input.DebugEventCallback = None
startup_input.SuppressBackgroundThread = False
startup_input.SuppressExternalCodecs = False
#正式啟動GDI+函式庫
GdiplusStartup(byref(gdiplus_token), byref(startup_input), None)

補充一下,startup_input有簡潔的寫法,如下
startup_input = GdiplusStartupInput(1, None, False, False)
不過為了把東西講清楚,上面還是先用詳細一點的寫法
以上程式碼的位置就是要放在建立WNDCLASS的前面[註]
下面提供的範例就是在WNDCLASS建立之前初始化
因為在測試ChatGPT提供的程式版本時
那版本的錯誤寫法是把GDI+初始化放在showWindow之後
變成流程上是先在WM_PAINT裡的程式先載入圖檔與繪製影像
然後在之後才初始化,這樣的執行即使可能沒有任何錯誤碼,圖還是跑不出來
不過也因為這樣才更能理解GDI+怎麼運作的
初始化後的重點就是讀檔與顯示了,我先捨棄說明範例中可以隨視窗大小調整的作法
先用GdipDrawImageI來做簡易流程說明

Windows API的繪圖顯示都在WM_PAINT裡完成
實際執行,需要PS這個結構體外
還要使用BeginPaint跟EndPaint放程式的最前面跟最後面
這些基本雖然忘了,但一看到範例碼就都想起來了
稍稍有點懷念當年看不懂這東西的奮戰日子(苦笑)
回歸正題,精簡後程式碼如下,我逐行解釋

ps = PAINTSTRUCT() #建立PS結構
hdc = user32.BeginPaint(hwnd, byref(ps)) #開始畫圖
#準備載入圖片,從HDC元件建立圖片指標
graphics = c_void_p()
GdipCreateFromHDC(hdc, byref(graphics))
#建立影像的指標,與圖檔路徑
image = POINTER(GpImage)()
image_path = os.path.abspath("example.jpg")
#啟用GDI+讀檔函式讀檔
GdipLoadImageFromFile(image_path, byref(image))
#將讀來的檔案指定給繪圖指標,並指定位置從(x=0, y=0)開始
GdipDrawImageI(graphics, image, 0, 0)
# 顯示後清理繪圖指標與影像指標
GdipDisposeImage(image)
GdipDeleteGraphics(graphics)
user32.EndPaint(hwnd, byref(ps)) #結束畫圖

最後當整個程式結束後
要關掉有使用的GDI+物件,如下面程式碼
# 清理GDI+
GdiplusShutdown(gdiplus_token)
雖然有測過不加這段通常也不會怎樣
不過照規矩釋放資源比較不會有什麼奇怪的bug
這個記得要寫啊

流程上就這樣而已,沒有很複雜
只是如前面所提,東西原本是包起來的
沒有靠AI的話,我還真的難以搞定
誰知道像DrawImage其實裡面包著其實是什麼GdipDrawImageI(位置指定用整數)
GdipDrawImageRect(位置指定用浮點數,可以指定圖片大小)
或GdipDrawImageRectI(位置指定用整數,同樣可以指定圖片大小)等等
C++可以單用DrawImage就搞定,Python不行啊
在放上完整可執行的程式碼前
再稍稍再解說一下一些上面說明沒提到的GDI+函數
GdipGetImageWidth、GdipGetImageHeight與GdipDrawImageRect
因為使用GdipDrawImageI只會照原始圖片大小繪製在視窗上
如果要能依視窗大小縮放圖片,就不能用這個
而是需要GdipGetImageWidth與GdipGetImageHeight取得圖片的高與寬
然後與視窗大小做運算得到等比例縮放後
再靠著GdipDrawImageRect繪製在視窗上
詳細的程式碼長這樣子:

from ctypes import WINFUNCTYPE, WinDLL, Structure, c_long, c_void_p, c_ulong
from ctypes import POINTER, byref, c_float
from ctypes.wintypes import HINSTANCE, HWND, UINT, WPARAM, LPARAM, MSG, RECT
from ctypes.wintypes import BOOL, INT, BYTE, LPCWSTR, HICON, HANDLE, HBRUSH, HDC
import os

# 定義必要的Windows API結構和常數
WNDPROC = WINFUNCTYPE(c_long, HWND, UINT, WPARAM, LPARAM)
user32 = WinDLL('user32')
kernel32 = WinDLL('kernel32')
gdiplus = WinDLL('gdiplus')

user32.DefWindowProcW.argtypes = [HWND, UINT, WPARAM, LPARAM]

# GDI+ 啟動結構
class GdiplusStartupInput(Structure):
    _fields_ = [
        ("GdiplusVersion", UINT),
        ("DebugEventCallback", c_void_p), #註1
        ("SuppressBackgroundThread", BOOL),
        ("SuppressExternalCodecs", BOOL),
    ]
#註1:因為實際通常指定為空,所以就不使用實際結構節省程式碼(就當我偷懶了)

# 視窗結構
class WNDCLASS(Structure):
    _fields_ = [
        ("style", UINT),
        ("lpfnWndProc", WNDPROC),
        ("cbClsExtra", INT),
        ("cbWndExtra", INT),
        ("hInstance", HINSTANCE),
        ("hIcon", HICON),
        ("hCursor", HANDLE),
        ("hbrBackground", HBRUSH),
        ("lpszMenuName", LPCWSTR),
        ("lpszClassName", LPCWSTR),
    ]

# 繪畫結構
class PAINTSTRUCT(Structure):
    _fields_ = [
        ("hdc", HDC),
        ("fErase", BOOL),
        ("rcPaint", RECT),
        ("fRestore", BOOL),
        ("fIncUpdate", BOOL),
        ("rgbReserved", BYTE * 32),
    ]

# GDI+ 圖像結構 (簡化版)
class GpImage(Structure):
    pass

# 定義GDI+函數的引數與回傳值
gdiplus.GdiplusStartup.argtypes = [POINTER(c_ulong), POINTER(GdiplusStartupInput), c_void_p]
gdiplus.GdiplusStartup.restype = UINT

gdiplus.GdipCreateFromHDC.argtypes = [HDC, POINTER(c_void_p)]
gdiplus.GdipCreateFromHDC.restype = UINT

gdiplus.GdipLoadImageFromFile.argtypes = [LPCWSTR, POINTER(POINTER(GpImage))]
gdiplus.GdipLoadImageFromFile.restype = UINT

gdiplus.GdipGetImageWidth.argtypes = [POINTER(GpImage), POINTER(UINT)]
gdiplus.GdipGetImageWidth.restype = UINT

gdiplus.GdipGetImageHeight.argtypes = [POINTER(GpImage), POINTER(UINT)]
gdiplus.GdipGetImageHeight.restype = UINT

gdiplus.GdipDrawImageRect.argtypes = [c_void_p, POINTER(GpImage), c_float, c_float, c_float, c_float]
gdiplus.GdipDrawImageRect.restype = UINT

gdiplus.GdipDisposeImage.argtypes = [POINTER(GpImage)]
gdiplus.GdipDisposeImage.restype = UINT

gdiplus.GdipDeleteGraphics.argtypes = [c_void_p]
gdiplus.GdipDeleteGraphics.restype = UINT

gdiplus.GdiplusShutdown.argtypes = [c_ulong]
gdiplus.GdiplusShutdown.restype = None

# 視窗過程函數
def wnd_proc(hwnd, msg, wparam, lparam):
    if msg == 0x000F:  # WM_PAINT
        ps = PAINTSTRUCT() #建立PS結構
        hdc = user32.BeginPaint(hwnd, byref(ps)) #開始畫圖
        
        # 取得視窗大小
        rect = RECT()
        user32.GetClientRect(hwnd, byref(rect))
        window_width = rect.right - rect.left
        window_height = rect.bottom - rect.top
        
        #準備載入圖片,從HDC元件建立圖片指標
        graphics = c_void_p()
        gdiplus.GdipCreateFromHDC(hdc, byref(graphics))
        
        #建立影像的指標,與圖檔路徑
        image = POINTER(GpImage)()
        image_path = os.path.abspath("example.jpg")
        #啟用GDI+讀檔函式讀檔
        gdiplus.GdipLoadImageFromFile(image_path, byref(image))
        
        # 取得圖片的寬與高
        img_width = UINT()
        img_height = UINT()
        gdiplus.GdipGetImageWidth(image, byref(img_width))
        gdiplus.GdipGetImageHeight(image, byref(img_height))
        
        # 計算縮放比例
        scale_x = window_width / img_width.value
        scale_y = window_height / img_height.value
        scale = min(scale_x, scale_y)
        
        scaled_width = img_width.value * scale
        scaled_height = img_height.value * scale
        
        # 讓圖片居中顯示
        x = (window_width - scaled_width) / 2
        y = (window_height - scaled_height) / 2
        
        gdiplus.GdipDrawImageRect(graphics, image, x, y, scaled_width, scaled_height)
        
        # 顯示後清理繪圖指標與影像指標
        gdiplus.GdipDisposeImage(image)
        gdiplus.GdipDeleteGraphics(graphics)
        user32.EndPaint(hwnd, byref(ps)) #結束畫圖
        
    elif msg == 0x0010:  # WM_CLOSE
        user32.PostQuitMessage(0)
        
    return user32.DefWindowProcW(hwnd, msg, wparam, lparam)

def main():
    # 初始化GDI+
    gdiplus_token = c_ulong()
    """
    這邊是詳細的寫法
    startup_input = GdiplusStartupInput()          #建立啟動結構
    startup_input.GdiplusVersion = 1               #版本指定1
    startup_input.DebugEventCallback = None        #DebugEvent不使用
    startup_input.SuppressBackgroundThread = False #不使用
    startup_input.SuppressExternalCodecs = False   #不使用 
    """
    #實務可以用這個簡易寫法
    startup_input = GdiplusStartupInput(1, None, False, False)
    #正式啟動GDI+函式庫
    gdiplus.GdiplusStartup(byref(gdiplus_token), byref(startup_input), None)
    
    # 設置視窗類別
    h_instance = kernel32.GetModuleHandleW(None)
    class_name = "SampleWindowClass"
    
    wc = WNDCLASS()
    wc.lpfnWndProc = WNDPROC(wnd_proc)
    wc.hInstance = h_instance
    wc.lpszClassName = class_name
    wc.hCursor = user32.LoadCursorW(None, LPCWSTR(32512))  # IDC_ARROW
    wc.hbrBackground = c_void_p(5 + 1)  # COLOR_WINDOW + 1,直接使用COLOR_WINDOW的值(5)加1
    
    user32.RegisterClassW(byref(wc))
    
    # 創建視窗
    hwnd = user32.CreateWindowExW(
        0,
        class_name,
        "用GDI+顯示圖片範例",
        0x00CF0000,  # WS_OVERLAPPEDWINDOW
        0x8000,      # CW_USEDEFAULT
        0x8000,      # CW_USEDEFAULT
        800,
        600,
        None,
        None,
        h_instance,
        None
    )
    
    if not hwnd:
        return
    
    user32.ShowWindow(hwnd, 1)  # SW_SHOWNORMAL
    user32.UpdateWindow(hwnd)
    
    # 消息循環
    msg = MSG()
    while user32.GetMessageW(byref(msg), None, 0, 0):
        user32.TranslateMessage(byref(msg))
        user32.DispatchMessageW(byref(msg))
    
    # 清理GDI+
    gdiplus.GdiplusShutdown(gdiplus_token)

if __name__ == "__main__":
    main()

不過,在主視窗直接用WM_PAINT的話
主視窗就只有顯示圖片而已了
要拿來寫遊戲或許可以直接這麼做
但要在上面加上其它元件只會變成這樣

是的,這程式寫法的圖就只是背景圖
要規畫放置的大小,也不是不行,但就沒什麼彈性

所以實際上要與其它GUI元件併存的做法
還是得用子視窗的方式處理會比較好擺放與縮放
考量到這篇的篇幅也不小了,再寫一篇好了
下一篇,再來弄個看圖程式範例
視窗上面有按鈕可以用點選目錄,然後讀取裡面圖檔顯示在右邊
左邊則顯示圖片的檔名,實際的大小與一些其它資訊
用這範例做為這次這個系列最終的成果

註:2025/08/06更新
原本這邊是寫「以上程式碼的位置要放在執行showWindow之前,在建立WNDCLASS的前或後都行」
不過後續有遇到其它狀況還是有問題,一步一步釐清才確認了一件事
更正確的寫法應該是要在CreateWindowExW之前
因為如果在WM_CREATE使用GDI+的元件,那就會在CreateWindowExW的時候就會執行GDI+
在這更之前就必須進行初始化
之前ChatGPT的範例,是那時GDI+是寫在WM_PAINT裡,所以執行showWindow時才會啟動
這樣初始化可以放在showWindow前就好了
那麼最好的作法,還是「GDI+初始化都寫在WNDCLASS註冊前」
確保絕對不會有事

沒有留言: