2025年8月8日 星期五

Python使用ctypes調用Windows API在64位元子視窗顯示圖像方法

承上一篇
本來是想在這一篇就作結的
想稍稍介紹上篇最後所提,圖像顯示的子視窗化後就接完整應用
只是圖像顯示的子視窗化的實作雖然有成功
可是三個實作方法裡,有一個是卡住的
直接跳過嗎?又很不甘心
實在不希望要再留個問題待未來處理
只好再多花時間解決它,然後再寫一篇
這篇介紹的就是子視窗化顯示圖像的三個作法


圖像顯示的子視窗化
詢問AI後,有得到三種作法
概分為兩大類
分別是使用靜態子視窗直接餵BITMAP(BMP格式)顯示
跟使用自訂Windows Procedure裡的WM_PAINT這兩大類
而後者的實作則有兩種
一種是先註冊Window Class(WNDCLASS;WNDCLASSEX)並在裡面插入自訂Window Procedure
另外一種是建立子視窗後,使用SetWindowLongPtrW動態插入Window Procedure
所以共三種作法
以下就對每一種的實作程式碼都一一介紹吧

第一種的寫法大概就是這樣(以下都當成已經執行GDI+初始化完成):

#先用GDI+載入圖片
bitmap_ptr = POINTER(GpImage)()
filename = "example.jpg"
gdiplus.GdipLoadImageFromFile(filename, ctypes.byref(bitmap_ptr))

#再使用GDI+轉BMP的函式轉為HBITMAP
hbitmap = HBITMAP() #wintypes.HBITMAP()
gdiplus.GdipCreateHBITMAPFromBitmap(bitmap_ptr, ctypes.byref(hbitmap), 0)

"""
#或改載入純BMP檔的作法
bmp_handle = user32.LoadImageW(
None,
"test.bmp",  
0, #IMAGE_BITMAP = 0
0, 0,
LR_LOADFROMFILE)
#"""

#建立靜態子視窗從10,10的位置,寬度640,高度480
hwnd_static = user32.CreateWindowExW(
        0, "STATIC", None,
        WS_CHILD | WS_VISIBLE | SS_BITMAP | WS_BORDER, 
        10, 10, 640, 480,
        hWnd, None, hInstance, None)
#使用SendMessageW將圖片傳入子視窗中
user32.SendMessageW(hwnd_static, 0x0172, 0, LPARAM.from_buffer(hbitmap))
#其中0x172代表STM_SETIMAGE,0代表IMAGE_BITMAP
"""
#使用LoadImageW讀檔時使用Handle傳入
user32.SendMessageW(hwnd_static, 0x0172, 0, bmp_handle) #IMAGE_BITMAP = 0
#"""

第一個作法步驟最少,直接餵子視窗BITMAP格式的問題低
如果讀取的圖檔是BMP的話,還可以直接用Windows API的LoadImageW載入
然後就整個圖像透過SendMessageW傳到子視窗上顯示
單以純顯示的話,可說沒話說地簡便
只是它不會調整大小,圖檔解析度多大就顯示多大,即便超過主視窗大小
它還是會把子視窗撐到跟圖檔一樣大


所以如果是用LoadImageW讀純BMP檔的話,AI建議讀檔時就要控制大小
也就是配合子視窗的大小去要求讀入的圖檔顯示與子視窗尺寸相符
只是這會有長寬比跑掉的問題
要解決這問題,最簡單就是代入上一篇寫的那個縮放程式後,再進行子視窗的縮放
這縮放的方法就是用Windows API裡的MoveWindow進行,程式如下

"""
取上一篇縮放範例,但不決定x,y位置
"""

#先取得子視窗的大小
rect = RECT()
user32.GetClientRect(hwnd_static, byref(rect))
window_width = rect.right - rect.left - 10  # 左右各留 5 像素
window_height = rect.bottom - rect.top - 10  # 上下各留 5 像素

#取得圖片的大小
img_width = UINT()
img_height = UINT()                
img_width.value = bitmap_info.bmWidth
img_height.value = bitmap_info.bmHeight
#gdiplus.GdipGetImageWidth(bitmap_ptr, byref(img_width))
#gdiplus.GdipGetImageHeight(bitmap_ptr, byref(img_height))

#如果影像大小都大於零,進行縮放運算
if img_width.value > 0 and img_height.value > 0:
    # 計算縮放比例
    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
    
    #與子視窗相同的10,10位置,縮放子視窗
    user32.MoveWindow(hwnd_static, 10, 10, int(scaled_width), int(scaled_height), True)
    
#強制更新子視窗圖像
user32.InvalidateRect(hwnd_static, None, True)

可以看到在MoveWindow後,還有加入InvalidateRect的函數
這是為了要求子視窗進WM_PAINT重繪圖像
不然就只看到子視窗變大變小,而圖像不變了
如果不採用上面的作法,就是要將控制寫在WM_PAINT裡,也就是寫Window Procedure處理
那其實就進入第二與第三種的作法了
還不如乾脆就用後兩者的寫法

第二種作法一樣先看程式再後說明吧
整個範例如下:

#先建立專門處理WM_PAINT裡GDI+顯示的Window Procedure
def image_wnd_proc(hwnd, msg, wparam, lparam):
    if msg == 0x000F:  # WM_PAINT
        ps = PAINTSTRUCT()
        hdc = user32.BeginPaint(hwnd, byref(ps))
        graphics = POINTER(GpImage)()
        gdiplus.GdipCreateFromHDC(hdc, byref(graphics))
        gdiplus.GdipDrawImageI(graphics, image, 0,0)
        user32.EndPaint(hwnd, byref(ps))
        return 0
    return user32.DefWindowProcW(hwnd, msg, wparam, lparam)

# 註冊子視窗類(用於圖片)
image_wc = WNDCLASS()
image_wc.lpfnWndProc = WNDPROC(image_wnd_proc) #將上面的Procedure丟給註冊的視窗
image_wc.hInstance = hInstance
image_wc.lpszClassName = "ImageWindowClass" #注意這裡的名稱
image_wc.hCursor = user32.LoadCursorW(None, LPCWSTR(32512))
image_wc.hbrBackground = c_void_p(5 + 1)
user32.RegisterClassW(byref(image_wc)) #進行註冊

# 再用GDI+載入圖片
bitmap_ptr = POINTER(GpImage)()
filename = "example.jpg"
gdiplus.GdipLoadImageFromFile(filename, ctypes.byref(bitmap_ptr))

#建立子視窗,但類別名要與上面註冊的相同
image_window_hwnd = user32.CreateWindowExW(
    0, "ImageWindowClass", None, #這裡的名稱跟上面註冊的要一樣
     WS_CHILD | WS_VISIBLE,
    10, 10, 640, 480,
    hWnd, None,hInstance, None)

第二種作法要寫的東西跟第一種比算多了一點,不過還算簡潔也很好理解
總之就是建立子視窗的物件,然後這物件有指定處理繪圖專用的Window Procedure
最後建立一個以這物件建立的子視窗來處理載入圖片的顯示
與第一種作法的顯示特性剛好相反
如果圖片大於子視窗的話,這作法會只顯示子視窗的大小,像下圖


原本寫到這裡,嫌這種作法比第一種還麻煩
不過實作到要控制顯示大小後反而了解這作法才是我要的最佳方案
因為只有它可以完美繼承上一篇內GDI+隨視窗大小縮放大小的程式碼
不論子視窗設定多大多小,圖片都可以正確地照原比例縮放顯示
還置中放在子視窗中間
可是第一種作法,其實是直接調整子視窗大小,因為圖片就等於子視窗了
所以它沒辦法把圖片放在子視窗的中央
相對於第二種作法還可以配合滑鼠或鍵盤進行縮放平移都沒問題
可說相較第一種來說有更好的擴充性

第三種作法,跟第二種差不多
但完全就是這次的魔王關,也就是開頭所提,卡住的部分
這題單問Grok與ChatGPT都解不太出來
說來這作法還是Grok先提給我的,建立靜態子視窗,然後再額外指定Window Procedure
結果關鍵的地方是靠ChatGPT來解的
但單跑ChatGPT給的範例也跑不出來,還要綜合之前所學的一起搞才成功
而且ChatGPT給的解也蠻奇特的,為什麼一定要掛裝飾器(Decorater)?
總之解決的重點有二個
一個就是最初學到的,傳入引數與回傳值要指定的問題
有互相關係的SetWindowLongPtrW與CallWindowProcW要好好指定好變數型態才傳得過去
再來就卡最久的,子視窗要用的Window Procedure函數要加上指定好函數的裝飾器
這個範例就是用WNDPROC
在處理的def img_wnd_proc之前加了@WNDPROC後
這樣傳入SetWindowLongPtrW時,才不用額外加WNDPROC類別指定,卻還是失敗
對,這就是最奇怪的地方
我有確認過,將image_wnd_proc加上WNDPROC後與加上裝飾器的型別都一樣
都是WINFUNCTYPE了
可是前者作法餵給WNDCLASS可以,餵給SetWindowPtrW不行
就一定要先加@WNDPROC後,再餵給SetWindowPtrW才行
不然就會在執行時產生異常,會跳出
OSError: exception: access violation writing 
甚至是什麼異常都沒跳出,程式卡住,然後就自己停止的狀況
神奇啊
不知道中間傳遞時出了什麼問題?
總之在這邊,可以執行的程式碼概略如下:

#指定以下兩個函數的引數與回傳值
user32.SetWindowLongPtrW.argtypes = [HWND, INT, WNDPROC]
user32.SetWindowLongPtrW.restype = WNDPROC
user32.CallWindowProcW.argtypes = [WNDPROC, HWND, UINT, WPARAM, LPARAM]

#先建立兩個全域變數
old_static_wndproc = None
bitmap_ptr = None

#一樣先建立專門處理WM_PAINT下GDI+的Window Procedure
#但要加上WNDPROC的裝飾器(Decorator)
@WNDPROC
def image_wnd_proc(hwnd, msg, wparam, lparam):
    if msg == 0x000F:  # WM_PAINT
        ps = PAINTSTRUCT()
        hdc = user32.BeginPaint(hwnd, byref(ps))
        graphics = POINTER(GpImage)()
        windll.gdiplus.GdipCreateFromHDC(hdc, byref(graphics))
        windll.gdiplus.GdipDrawImageI(graphics, bitmap_ptr, 0,0)
        user32.EndPaint(hwnd, byref(ps))
        return 0
    #這邊改成user32.CallWindowProcW    
    return user32.CallWindowProcW(original_static_proc, hwnd, msg, wparam, lparam)

"""
這邊Window主視窗與GDI+的初始化略過
"""

# 再用GDI+載入圖片
bitmap_ptr = POINTER(GpImage)()
filename = "example.jpg"
GdipLoadImageFromFile(filename, ctypes.byref(bitmap_ptr))

#建立子視窗,類別名稱要用STATIC
image_window_hwnd = user32.CreateWindowExW(
    0, "STATIC", None, #這裡的名稱要用STATIC
     WS_CHILD | WS_VISIBLE,
    10, 10, 640, 480,
    hWnd, None, hInstance, None)
    
#將上面的影像Procedure丟給建立靜態視窗
original_static_proc = user32.SetWindowLongPtrW(image_window_hwnd, -4, image_wnd_proc)
#-4是指GWL_WNDPROC

這樣就能顯示圖片了

但是魔王關就是魔王關,接下來又遇上兩個問題
一個就是子視窗接受不到WM_LBUTTONDOWN事件
後來一查,只要指定子視窗為STATIC(靜態)就不會接受滑鼠事件
所以如果想與滑鼠互動控制,就不能用「靜態」的屬性
再來就是包裝成物件的問題,也是真正的第二道魔王關
問題出在因為Python包裝成物件,引數會多一個self

class pywin:
    @WNDPROC
    def image_wnd_proc(self, hwnd, msg, wparam, lparam):
        if msg == 0x000F:  # WM_PAINT
            ps = PAINTSTRUCT()
            hdc = user32.BeginPaint(hwnd, byref(ps))
            graphics = POINTER(GpImage)()
            windll.gdiplus.GdipCreateFromHDC(hdc, byref(graphics))
            #這邊bitmap_ptr要加上self.
            windll.gdiplus.GdipDrawImageI(graphics, self.bitmap_ptr, 0,0)
            user32.EndPaint(hwnd, byref(ps))
            return 0
        #這邊original_static_proc要加上self.
        return user32.CallWindowProcW(self.original_static_proc, hwnd, msg, wparam, lparam)   

所以如果image_wnd_proc變物件函數,當傳遞到SetWindowLongPtrW時
錯誤訊息很明顯少了一組引數,數量不對


這邊試過一大堆方法
其實最簡單的就是image_wnd_proc繼續維持一般函數,繼續掛裝飾器
然後把bitmap_ptr與original_static_proc全域化就行
但都已經包成物件了,還有這兩個沒一起包成物件就是覺得不夠完美
反正已經決定挑戰到底,而且有AI可以問,怕什麼
就一直簡化問題來問AI
最後是依ChatGPT的前面可執行程式改版成簡易物件型式,連同錯誤碼一起丟上去
才出現一個辦法
很有趣的是這個辦法其實跟上面的操作牴觸
這辦法就是拿掉裝飾器後,把函數包個WNDPROC餵給物件變數就行了
像這樣

class pywin:
    #為避免繼承舊物件時,指定變數順序錯誤,建議這個一律放這裡
    original_static_proc = None 
        
    #取消@WNDPROC的指定
    def image_wnd_proc(self, hwnd, msg, wparam, lparam):        
        """
        內容省略
        """
        return user32.CallWindowProcW(self.original_static_proc, hwnd, msg, wparam, lparam)
    
    def __init__(self):
        """
        Window主視窗與GDI+的初始化略過
        """
        
        #在建立子視窗前,一定要建立這個變數為物件內建變數
        self.original_static_proc = None 
        
        #建立子視窗
        image_window_hwnd = user32.CreateWindowExW(
        0, "STATIC", None, #這裡的名稱暫時用STATIC
         WS_CHILD | WS_VISIBLE,
        10, 10, 640, 480,
        hWnd, None, hInstance, None)
        
        #將上面的影像Procedure丟給建立靜態視窗
        self.static_proc = WNDPROC(image_wnd_proc) #非物件的函數這樣做不行,結果這邊可以....
        #再把self.static_proc丟到SetWindowLongPtrW中
        self.original_static_proc = user32.SetWindowLongPtrW(image_window_hwnd, -4, self.static_proc)
        #-4是指GWL_WNDPROC

弄出這個後,心中的大石總算放下來了一半
雖然還是不懂為什麼,但至少兩個作法都搞定了
然後……

然後就在打完上面的初稿後沒多久,我註冊了Claude並問了為什麼會有這兩種作法
它告訴我一個很明確,我覺得就是的答案了
簡單一個重點,「Python處理C語言資料要避免資料被回收」
因為Python有垃圾回收機制,會處理掉用不到的資料
那C語言很多資料其實是指標
這個指標指向的資料,如果是Python建的,Python又覺得是垃圾清掉後
就會產生出那些問題
為什麼用裝飾器就不會,是因為在函數前加裝飾器@WNDPROC其實等於
在函數後寫上這行
img_wnd_proc = WNDPROC(img_wnd_proc)
也就是把函數img_wnd_proc轉成一個全域實際存在的變數了
而為什麼直接用WNDPROC(img_wnd_proc)放在SetWindowLongPtrW裡不行
是因為雖然有轉成正確的型別,但轉完後沒暫存起舊資料
像影像處理這裡只有轉,沒有存,只傳了指標過去後,資料可能消失了,自然會出問題
而前面主Window Procedure轉完後有存入Window Class裡面沒被清掉,就可以正常地跑
同樣,在包成物件時,因為存入物件的變數等同可以一直存在,所以這樣處理反而沒問題
當然如果是只存一般暫存變數,那還是會出問題
真的是懂(學)太少了啊

三種作法都實作出來也搞清楚最後做不出來與做得出來的問題出在哪裡了
如前所說,我是決定用第二種作法比較適合我要的應用
因為第一種雖然可以達成等比例縮放,但會是整個子視窗跟著縮放,實際配置會跑掉
而實作上又不需要第三種作法的高彈性,雖然已經搞懂怎麼做了
那就是決定用第二種
下一篇就是搞定實作出來的程式啦

沒有留言: