2024年3月20日 星期三

Python-自製螢幕截圖程式

要實現螢幕截圖有許多方式,以 Windows 為例,可以使用 Win32 API 的 StretchBlt 搭配 CreateCompatibleBitmap 來實作;若為了效能考量,也可藉由 DirectX 來處理。但使用這些方法有一個最大的問題,就是難以跨平台。因此,本喵基於「懶惰是程式設計師的美德」,直接使用眾多大神所製作的第三方套件來實現此功能。

首先來看看螢幕截圖程式的演示:

咱們要實現的功能很單純,整體流程如下:

  1. 按下鍵盤上的 PrintScreen 鍵來取得整個螢幕畫面
  2. 框選欲裁切的範圍
  3. 確認目標後顯示框選的螢幕區域

監聽鍵盤訊息

啟動截圖的第一步是確認 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 內。各位看官可以試著將其移除,以觀察程式的變化。

框選範圍

要選取範圍可以如下進行:

  1. 按下滑鼠左鍵以啟動目標選取作業
  2. 在滑鼠左鍵已按下的狀態下,拖曳滑鼠來框選範圍
  3. 放開滑鼠左鍵以完成選取動作

當然也可以以其他方式進行,這全憑程式設計者的想像力。

在 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(),以免全域變數流竄各處。至此,基本功能的實現講述完畢。

2019年1月8日 星期二

22-來山寨Monkeyrunner吧 (實作一個弧形拖曳)

各位看官在讀完 sendevent 的介紹後有什麼感想呢?是不是覺得使用 sendevent 有點繁瑣?若用在模擬按鍵的操作上似乎還勉強能夠接受,但一旦要模擬觸控螢幕的操作,比如拖曳,則似乎有些累人。雖然這問題可藉由良好的封裝來解決,但本喵今天就來為各位看官出個蠢主意——山寨一個 Monkeyrunner 吧!!

2018年12月30日 星期日

21-sendevent

上回咱們說到怎麼利用 getevent 得到 Android 的輸入事件,本次就來說明如何使用 sendevent 模擬輸入的動作。

2018年12月23日 星期日

20-getevent

getevent 是一個和模擬輸入無直接相關的指令,但若看官想知道當滑動 Android 觸控面板時是由那些輸入事件所構成,或想記錄點擊、滑動等動作以回放這些操作,那 getevent 將是一種選擇。

2018年12月8日 星期六

19-Level up !!! input draganddrop

拖放 (Drag and Drop) 是種很常見的操作,可是咱們曾介紹的 MonkeyDevice.draginput swipe 都難以漂亮地模擬此動作,並非不能,只是實現的效果非常不理想且醜陋。不過從 Android 8.0.0 開始,有一新的 input 成員能夠較為優雅地實現此模擬了,它便是 input draganddrop

2018年12月2日 星期日

18-input三兄弟swipe

input swipe 的功能和 MonkeyDevice.drag 類似,都是模擬手指滑動螢幕的行為,其使用方式也雷同:
語法 input swipe <x1> <y1> <x2> <y2> [ duration(ms) ]
說明 功能與 MonkeyDevice.drag (tuple start, tuple end, float duration, integer steps) 類似。
duration 預設為 300 ms。

2018年11月5日 星期一