要實現螢幕截圖有許多方式,以 Windows 為例,可以使用 Win32 API 的 StretchBlt 搭配 CreateCompatibleBitmap 來實作;若為了效能考量,也可藉由 DirectX 來處理。但使用這些方法有一個最大的問題,就是難以跨平台。因此,本喵基於「懶惰是程式設計師的美德」,直接使用眾多大神所製作的第三方套件來實現此功能。
首先來看看螢幕截圖程式的演示:
咱們要實現的功能很單純,整體流程如下:
- 按下鍵盤上的 PrintScreen 鍵來取得整個螢幕畫面
- 框選欲裁切的範圍
- 確認目標後顯示框選的螢幕區域
監聽鍵盤訊息
啟動截圖的第一步是確認 PrintScreen 鍵是否已被按下。只是這裡有個難點,就是當鍵盤被按下時,若此時的鍵盤輸入焦點不在咱們的程式上,在沒有特殊處理下,咱們的程式是無法得知此訊息的。為了監聽在任意視窗下的鍵盤訊息,咱們可以使用第三方套件 pynput,它的簡易用法如下:
from pynput import keyboard
def on_key_down(key):
print(f"key down: {key}")
def on_key_up(key):
print(f"key upa: {key}")
if key == keyboard.Key.esc:
return False
with keyboard.Listener(on_press=on_key_down, on_release=on_key_up) as listener:
listener.join()
有時後,程式可能需要在中途停止監聽鍵盤。比如按下 PrintScreen 後,需要使用者框選裁切區域,這時咱們可能不希望使用者再次按下 PrintScreen 後,又進行擷取螢幕的動作。為了能停止監聽鍵盤,咱們可以如下使用 pynput:
from threading import Thread
from tkinter import *
from pynput import keyboard
def on_key_up(key):
print(f"key up: {key}")
if key == keyboard.Key.esc:
# 若不使用 thread,
# 而是直接呼叫 _root_win.destroy(),
# 將無法執行到 return False,
# 這會導致之後的 listener.join() 無法結束
Thread(target=_root_win.destroy).start()
return False
_root_win = Tk()
listener = keyboard.Listener(on_release=on_key_up)
listener.start()
# 某些會導致程式停在此處的指令,比如以下的 mainloop()
_root_win.mainloop()
print(f"listener is running: {listener.running}")
listener.stop()
print(f"listener is running: {listener.running}")
listener.join()
要使用何種型式,端視程式架構而異。
另外,如 on_key_up() 裡的注解所示,若 listener 的 on_press 和 on_release 所指定的函式都無法回傳 False,當主執行緒執行到 listener.join() 時,listener.join() 將無法結束。各位看官可以更改 on_key_up() 的流程,並以按鍵 Esc 或以關閉視窗的方式來結束程式,並觀察 listener.running 的數值變化。
有一點需要注意,當使用了 listener.stop() 後,若要再次啟動此 listener,無法藉由直接呼叫 listener.start() 來重新啟用它,而是需要創建一個新的 keyboard.Listener 物件,如第 19 和 20 行。
擷取螢幕畫面
要擷取目前螢幕畫面,可以使用 Pillow,它的使用方式非常簡單:
from PIL import ImageGrab
screenshot = ImageGrab.grab()
screenshot 就是目前的螢幕畫面,其類型是 PIL.Image.Image。不過因為要將其顯示在 Tkinter 的視窗內,所以須要將其轉換成 Tkinter 所能接受的影像物件 PIL.ImageTk.PhotoImage,其流程如下:
from tkinter import *
from PIL import ImageGrab, ImageTk
# 創建主視窗
_root_win = Tk()
# 取得螢幕大小
cx_screen = _root_win.winfo_screenwidth()
cy_screen = _root_win.winfo_screenheight()
# 創建畫布以繪製取得的螢幕內容
# highlightthickness 設為 0,
# 否則 Canvas 預設會在周圍加 2 pixel 的邊框
_canvas = Canvas(_root_win, width=cx_screen, height=cy_screen, highlightthickness=0)
_canvas.pack()
# 取得目前螢幕畫面,
# 並轉換成 Tkinter 可識別的物件
screenshot = ImageGrab.grab()
img_tk = ImageTk.PhotoImage(screenshot)
# 將截圖繪製到螢幕上
# 注意:
# create_image() 雖然會以 image 接收 img_tk,
# 但實際上並不保留 img_tk 參考
_canvas.create_image(0, 0, anchor=NW, image=img_tk)
# 監聽 Esc 按鍵的訊息,當按下 Esc 後,會結束主視窗
_root_win.bind("<Escape>", lambda _: _root_win.destroy())
# 將主視窗轉換成全螢幕
_root_win.attributes("-fullscreen", 1)
_root_win.mainloop()
這裡必須特別注意第 27 行的 _canvas.create_image()。如注釋所示,create_image() 並沒有保留 img_tk 的參考。現在因 img_tk 是全域變數,所以 _canvas 可以正常顯示圖像。但若 img_tk 是區域變數,在離開作用域後將會被資源回收處理器清除掉,這將導致 _canvas 無法顯示圖像。因此,必須確保 img_tk 所參考的物件的有效時間至少與 _canvas 的生存時間一樣。
結合鍵盤監控和螢幕擷取
至此,咱們已經知道如何監聽全域的鍵盤事件,以及擷取螢幕畫面的方法。當結合兩者後,便能在任意視窗下,取得感興趣的螢幕副本:
# Standard modules
from threading import Thread
from tkinter import *
# 3rd party modules
from PIL import Image, ImageGrab, ImageTk
from pynput import keyboard
class MainApp:
def __init__(self):
self.root_win = Tk()
cx_screen = self.root_win.winfo_screenwidth()
cy_screen = self.root_win.winfo_screenheight()
self.canvas = Canvas(
self.root_win, width=cx_screen, height=cy_screen, highlightthickness=0
)
self.canvas.pack()
self.root_win.bind("<Escape>", self.on_destroy)
# 隱藏主視窗
self.root_win.withdraw()
def destroy(self):
self.root_win.destroy()
def fullscreen(self):
self.root_win.deiconify()
self.root_win.attributes("-fullscreen", 1)
def mainloop(self):
self.root_win.mainloop()
def screenshot(self):
self.img_screenshot = ImageGrab.grab()
self.draw_screenshot()
self.fullscreen()
def draw_screenshot(self):
img = self.build_screenshot()
self.canvas.delete(ALL)
self.canvas.create_image(0, 0, anchor=NW, image=img)
self.canvas.img_tk = img
def build_screenshot(self):
shadow = Image.new("RGB", self.img_screenshot.size, (0xF0, 0xF8, 0xFF))
alpha = 0.75
blend = Image.blend(self.img_screenshot, shadow, alpha)
img = ImageTk.PhotoImage(blend)
return img
def on_destroy(self, event):
self.destroy()
def on_key_up(key):
if key == keyboard.Key.print_screen:
_listener.stop()
_app.screenshot()
return False
elif key == keyboard.Key.esc:
Thread(target=_app.destroy).start()
return False
if __name__ == "__main__":
_app = MainApp()
_listener = keyboard.Listener(on_release=on_key_up)
_listener.start()
_app.mainloop()
_listener.stop()
_listener.join()
為了方便管理,本喵將主視窗封裝成 MainApp 類別,讓其負責擷取螢幕圖像與顯示。基本上整體與前面的程式沒有太大不同,最大差別在第 48 行的 build_screenshot(),它負責渲染擷取的螢幕。首先,在螢幕副本上覆蓋一個半透明圖層 shadow,以此區分真正螢幕和副本。然後將其轉換成 Tkinter 所能識別的影像物件,讓第 42 行的 draw_screenshot() 可以將其繪出。
另外,還記得先前曾說過,create_image() 並不會保存傳進去的影像參考嗎?為了保存參考,本喵在第 46 行,將其保存在 canvas 內。各位看官可以試著將其移除,以觀察程式的變化。
框選範圍
要選取範圍可以如下進行:
- 按下滑鼠左鍵以啟動目標選取作業
- 在滑鼠左鍵已按下的狀態下,拖曳滑鼠來框選範圍
- 放開滑鼠左鍵以完成選取動作
當然也可以以其他方式進行,這全憑程式設計者的想像力。
在 Tkinter,要監聽滑鼠事件,需要使用到視窗物件的 bind() 函式,以下是會用到的事件:
事件 | 描述 |
---|---|
<Button-1> | 當滑鼠左鍵被按下時觸發 |
<ButtonRelease-1> | 當滑鼠左鍵被放開時觸發 |
<B1-Motion> | 當滑鼠左鍵被按下,並移動滑鼠時觸發(即按下左鍵拖曳) |
咱們先來寫個簡易的框選程式:
from tkinter import *
def normalize_roi(roi: list[int]) -> list[int]:
"""
Tkinter 的坐標系是由左往右,由上往下遞增,
而 roi 所儲存的資訊是 (左, 上, 右, 下),
但可能 左 > 右 或 上 > 下,
所以需要正規化
"""
result = roi.copy()
if result[0] > result[2]:
result[0], result[2] = result[2], result[0]
if result[1] > result[3]:
result[1], result[3] = result[3], result[1]
return result
def draw_roi(roi: list[int]):
roi = normalize_roi(roi)
_canvas.delete(ALL)
_canvas.create_rectangle(roi, outline="red")
def on_left_mouse_down(event):
_roi[0] = _roi[2] = event.x
_roi[1] = _roi[3] = event.y
def on_left_mouse_drag(event):
_roi[2] = event.x
_roi[3] = event.y
draw_roi(_roi)
# 表示框選座標 (左, 上, 右, 下)
_roi = [0] * 4
_root_win = Tk()
cx_win = 800
cy_win = 600
_canvas = Canvas(_root_win, width=cx_win, height=cy_win, bg="#177245")
_canvas.pack()
# 繫結主視窗的滑鼠事件
_root_win.bind("<Button-1>", on_left_mouse_down)
_root_win.bind("<B1-Motion>", on_left_mouse_drag)
_root_win.mainloop()
各位看官應該會發現,當要擴大選取範圍時,選取框擴大方向的邊有時會消失不見。要改善此狀況,可以在繪製選取框前,先將整個繪製區域填滿背景色,如以下程式的第 28 和 29 行:
from tkinter import *
def normalize_roi(roi: list[int]) -> list[int]:
"""
Tkinter 的坐標系是由左往右,由上往下遞增,
而 roi 所儲存的資訊是 (左, 上, 右, 下),
但可能 左 > 右 或 上 > 下,
所以需要正規化
"""
result = roi.copy()
if result[0] > result[2]:
result[0], result[2] = result[2], result[0]
if result[1] > result[3]:
result[1], result[3] = result[3], result[1]
return result
def draw_roi(roi: list[int]):
roi = normalize_roi(roi)
_canvas.delete(ALL)
# 填滿背景色,以防止選取框擴大方向的邊消失
bg = _canvas.config("bg")[-1]
_canvas.create_rectangle((0, 0, _cx_win, _cy_win), fill=bg, outline=bg)
_canvas.create_rectangle(roi, outline="red")
def on_left_mouse_down(event):
_roi[0] = _roi[2] = event.x
_roi[1] = _roi[3] = event.y
def on_left_mouse_drag(event):
_roi[2] = event.x
_roi[3] = event.y
draw_roi(_roi)
# 表示框選座標 (左, 上, 右, 下)
_roi = [0] * 4
_root_win = Tk()
_cx_win = 800
_cy_win = 600
_canvas = Canvas(_root_win, width=_cx_win, height=_cy_win, bg="#177245")
_canvas.pack()
# 繫結主視窗的滑鼠事件
_root_win.bind("<Button-1>", on_left_mouse_down)
_root_win.bind("<B1-Motion>", on_left_mouse_drag)
_root_win.mainloop()
目前並不清楚為何有這現象。本喵一開始以為是缺乏雙緩衝所導致,但觀察其現象,並不像是如此。而 _canvas.delete(ALL) 應該也已經以背景色將先前的框選範圍清除,所以應該不須自行將背景重新繪製才是。若有看官瞭解其原因,還煩請告知。
鍵盤監控 + 螢幕擷取 + 範圍選取
接著咱們結合先前的螢幕擷取程式:
# Standard modules
from threading import Thread
from tkinter import *
# 3rd party modules
from PIL import Image, ImageDraw, ImageGrab, ImageTk
from pynput import keyboard
class MainApp:
def __init__(self):
# 表示框選座標 (左, 上, 右, 下)
self.roi = [0] * 4
self.root_win = Tk()
cx_screen = self.root_win.winfo_screenwidth()
cy_screen = self.root_win.winfo_screenheight()
self.canvas = Canvas(
self.root_win, width=cx_screen, height=cy_screen, highlightthickness=0
)
self.canvas.pack()
self.root_win.bind("<Escape>", self.on_destroy)
self.root_win.bind("<Button-1>", self.on_left_mouse_down)
self.root_win.bind("<B1-Motion>", self.on_left_mouse_drag)
self.root_win.withdraw()
def destroy(self):
self.root_win.destroy()
def fullscreen(self):
self.root_win.deiconify()
self.root_win.attributes("-fullscreen", 1)
def mainloop(self):
self.root_win.mainloop()
def screenshot(self):
self.img_screenshot = ImageGrab.grab()
self.draw_screenshot()
self.fullscreen()
def draw_screenshot(self, roi: list[int] = None):
img = self.build_screenshot(roi)
self.canvas.delete(ALL)
self.canvas.create_image(0, 0, anchor=NW, image=img)
self.canvas.img_tk = img
def build_screenshot(self, roi: list[int] = None):
shadow = Image.new("RGB", self.img_screenshot.size, (0xF0, 0xF8, 0xFF))
alpha = 0.75
blend = Image.blend(self.img_screenshot, shadow, alpha)
if roi and roi[0] != roi[2] and roi[1] != roi[3]:
roi = self.normalize_roi(roi)
# 建立遮罩,讓後續複製操作僅作用於所選範圍
mask = Image.new("1", self.img_screenshot.size)
draw = ImageDraw.Draw(mask)
draw.rectangle(roi, fill=1)
# 將選取的螢幕畫面貼到被半透明圖層覆蓋的螢幕上
blend.paste(self.img_screenshot, None, mask)
# 繪製一個紅色的選取框
draw = ImageDraw.Draw(blend)
draw.rectangle(roi, outline="red")
img = ImageTk.PhotoImage(blend)
return img
def draw_roi(self):
self.draw_screenshot(self.roi)
def normalize_roi(self, roi: list[int]) -> list[int]:
result = roi.copy()
if result[0] > result[2]:
result[0], result[2] = result[2], result[0]
if result[1] > result[3]:
result[1], result[3] = result[3], result[1]
return result
def on_destroy(self, event):
self.destroy()
def on_left_mouse_down(self, event):
self.roi[0] = self.roi[2] = event.x
self.roi[1] = self.roi[3] = event.y
def on_left_mouse_drag(self, event):
self.roi[2] = event.x
self.roi[3] = event.y
self.draw_roi()
def on_key_up(key):
if key == keyboard.Key.print_screen:
_listener.stop()
_app.screenshot()
return False
elif key == keyboard.Key.esc:
Thread(target=_app.destroy).start()
return False
if __name__ == "__main__":
_app = MainApp()
_listener = keyboard.Listener(on_release=on_key_up)
_listener.start()
_app.mainloop()
_listener.stop()
_listener.join()
本喵在第 52 行的 build_screenshot() 統一處理選取框的繪製。因為先前已經將整個螢幕副本覆蓋了一層半透明的圖層,以 blend 變數指代,所以現在只要將被框選的螢幕繪製到 blend 上就好。不過因為 Pillow.Image.Image.paste() 必須藉由遮罩來進行部分複製的操作,所以在第 61 ~ 63 行本喵製作了一個與螢幕同大小的遮罩,並將選取區域的值設為 1(1 表示會處理,0 則不處理),然後在第 66 行將圖貼到 blend 上。
另外,為了在淺色畫面也能看清選取範圍,於是本喵在第 70 行繪製了一個紅色的選取框。
確認選取範圍與顯示
確認選取範圍很簡單,只要偵測到滑鼠左鍵被放開,便顯示一個對話方塊詢問使用者,若同意,便顯示此截圖;否則讓使用者重新選取區域:
# Standard modules
from threading import Thread
from tkinter import *
from tkinter import messagebox
# 3rd party modules
from PIL import Image, ImageDraw, ImageGrab, ImageTk
from pynput import keyboard
class MainApp:
def __init__(self):
# 表示框選座標 (左, 上, 右, 下)
self.roi = [0] * 4
self.img_roi_number = 0
self.root_win = Tk()
cx_screen = self.root_win.winfo_screenwidth()
cy_screen = self.root_win.winfo_screenheight()
self.canvas = Canvas(
self.root_win, width=cx_screen, height=cy_screen, highlightthickness=0
)
self.canvas.pack()
self.root_win.bind("<Escape>", self.on_destroy)
self.root_win.bind("<Button-1>", self.on_left_mouse_down)
self.root_win.bind("<B1-Motion>", self.on_left_mouse_drag)
self.root_win.bind("<ButtonRelease-1>", self.on_left_mouse_up)
self.root_win.withdraw()
def destroy(self):
self.root_win.destroy()
def fullscreen(self):
self.root_win.deiconify()
self.root_win.attributes("-fullscreen", 1)
def mainloop(self):
self.root_win.mainloop()
def screenshot(self):
self.img_screenshot = ImageGrab.grab()
self.draw_screenshot()
self.fullscreen()
def draw_screenshot(self, roi: list[int] = None):
img = self.build_screenshot(roi)
self.canvas.delete(ALL)
self.canvas.create_image(0, 0, anchor=NW, image=img)
self.canvas.img_tk = img
def build_screenshot(self, roi: list[int] = None):
shadow = Image.new("RGB", self.img_screenshot.size, (0xF0, 0xF8, 0xFF))
alpha = 0.75
blend = Image.blend(self.img_screenshot, shadow, alpha)
if roi and roi[0] != roi[2] and roi[1] != roi[3]:
roi = self.normalize_roi(roi)
# 建立遮罩,讓後續複製操作僅作用於所選範圍
mask = Image.new("1", self.img_screenshot.size)
draw = ImageDraw.Draw(mask)
draw.rectangle(roi, fill=1)
# 將選取的螢幕畫面貼到被半透明圖層覆蓋的螢幕上
blend.paste(self.img_screenshot, None, mask)
# 繪製一個紅色的選取框
draw = ImageDraw.Draw(blend)
draw.rectangle(roi, outline="red")
img = ImageTk.PhotoImage(blend)
return img
def draw_roi(self):
self.draw_screenshot(self.roi)
def normalize_roi(self, roi: list[int]) -> list[int]:
result = roi.copy()
if result[0] > result[2]:
result[0], result[2] = result[2], result[0]
if result[1] > result[3]:
result[1], result[3] = result[3], result[1]
return result
def show_roi(self):
self.img_roi_number += 1
title = f"Capture {self.img_roi_number}"
roi = self.normalize_roi(self.roi)
img = self.img_screenshot.crop((roi[0], roi[1], roi[2] + 1, roi[3] + 1))
roi_win = RoiWin(img, title)
def on_destroy(self, event):
self.destroy()
def on_left_mouse_down(self, event):
self.roi[0] = self.roi[2] = event.x
self.roi[1] = self.roi[3] = event.y
def on_left_mouse_drag(self, event):
self.roi[2] = event.x
self.roi[3] = event.y
self.draw_roi()
def on_left_mouse_up(self, event):
if self.roi[0] != self.roi[2] and self.roi[1] != self.roi[3]:
if messagebox.askokcancel("選取區域", "接受目前的選取區域嗎?"):
self.show_roi()
self.root_win.withdraw()
start_keyboard_listener()
return
self.roi[0] = self.roi[1] = self.roi[2] = self.roi[3] = 0
self.draw_screenshot()
class RoiWin(Toplevel):
def __init__(self, img: Image.Image, title: str = None) -> None:
super().__init__()
self.img = img
if title:
self.title(title)
self.canvas = Canvas(self, width=img.width, height=img.height)
self.canvas.pack(anchor=NW)
self.on_paint()
self.attributes("-topmost", True)
self.after_idle(self.attributes, "-topmost", False)
def on_paint(self):
self.canvas.delete(ALL)
img_tk = ImageTk.PhotoImage(self.img)
self.canvas.create_image(0, 0, anchor=NW, image=img_tk)
self.canvas.img_tk = img_tk
def on_key_up(key):
if key == keyboard.Key.print_screen:
stop_keyboard_listener()
_app.screenshot()
return False
elif key == keyboard.Key.esc:
Thread(target=_app.destroy).start()
return False
def start_keyboard_listener():
global _listener
_listener = keyboard.Listener(on_release=on_key_up)
_listener.start()
def stop_keyboard_listener(wait_need=False):
_listener.stop()
if wait_need:
_listener.join()
if __name__ == "__main__":
_app = MainApp()
start_keyboard_listener()
_app.mainloop()
stop_keyboard_listener(True)
在第 115 行增加 on_left_mouse_up(),以執行滑鼠左鍵被放開時的命令。然後新增 RoiWin 類別,來單獨顯示所有被框選的區域。最後為了更好管控鍵盤的監控開關,在 163 與 170 增加 start_keyboard_listener() 和 stop_keyboard_listener(),以免全域變數流竄各處。至此,基本功能的實現講述完畢。