下述代码实现了高性能(10^-5次方级别)的截图区域亮暗度比对,避免了常规通过PhotoImage反复构建半透明蒙版的思路:
import tkinter as tk
from PIL import Image, ImageTk
import ctypes
class TransparentOverlayApp(tk.Tk):
alpha = 80 # 透明程度(0 - 255, 越小越透明)
def __init__(self, image_path: str):
super().__init__()
self.image: Image.Image = None
self.rect_pos: list[int, int, int, int] = [0, 0, 0, 0]
self.local_image_canvas: tk.Canvas = None
self.local_window: int = None # self.canvas.create_window返回的id
self.local_image: int = None # create_image返回的id
self.title("仿微信截图区域暗亮比对")
self.load_image(image_path)
self.canvas: tk.Canvas = self.set_basic_canvas()
self.config_basic_canvas()
def load_image(self, image_path) -> None:
self.image = Image.open(image_path)
self.orig_imagetk = ImageTk.PhotoImage(self.image)
self.overlay_imagetk = ImageTk.PhotoImage(self.overlay_image(self.image))
def set_basic_canvas(self) -> tk.Canvas:
canvas = tk.Canvas(self, width=self.image.width, height=self.image.height)
canvas.pack()
return canvas
def config_basic_canvas(self) -> None:
self.orig_imageid = self.canvas.create_image(0, 0, anchor="nw", image=self.orig_imagetk)
self.canvas.bind("<ButtonPress-1>", self.start_overlay)
self.canvas.bind("<ButtonRelease-1>", self.stop_overlay)
def overlay_image(self, image: Image.Image) -> Image.Image:
# 此处调节暗色区域的透明度
overlay = Image.new(
"RGBA", (self.image.width, self.image.height), (0, 0, 0, self.__class__.alpha)
)
overlayed_image = Image.alpha_composite(image.convert("RGBA"), overlay)
return overlayed_image
def start_overlay(self, event) -> None:
self.canvas.itemconfig(self.orig_imageid, image=self.overlay_imagetk)
self.local_image_canvas = tk.Canvas(self, highlightthickness=2, highlightbackground="#1AAE1A")
self.local_image = self.local_image_canvas.create_image(0, 0, anchor="nw", image=self.orig_imagetk)
self.local_window = self.canvas.create_window(-self.image.width, -self.image.height, anchor="nw", window=self.local_image_canvas)
self.rect_pos = [event.x, event.y, event.x, event.y]
self.canvas.bind("<B1-Motion>", self.update_overlay)
def update_overlay(self, event) -> None:
self.rect_pos[2] = event.x
self.rect_pos[3] = event.y
self.update_canvas()
def stop_overlay(self, _) -> None:
self.canvas.unbind("<B1-Motion>")
self.canvas.itemconfig(self.orig_imageid, image=self.orig_imagetk)
self.canvas.delete(self.local_window)
self.local_image_canvas.destroy()
self.local_window = None
self.local_image = None
def update_canvas(self) -> None:
x1, y1, x2, y2 = self.rect_pos
# 确保坐标是有效的
x1, y1, x2, y2 = min(x1, x2), min(y1, y2), max(x1, x2), max(y1, y2)
self.canvas.itemconfig(self.local_window, width=max(x2 - x1, 1), height=max(y2 - y1, 1)) # 新大小
self.canvas.coords(self.local_window, x1, y1)
self.local_image_canvas.coords(self.local_image, -x1, -y1)
if __name__ == "__main__":
# 自动替换为你的文件路径
image_path = r"D:\Users\pbl\Desktop\2879x1795.png"
ctypes.windll.shcore.SetProcessDpiAwareness(1)
app = TransparentOverlayApp(image_path)
app.mainloop()
效果图:
效果:拖拽矩形,矩形区域内颜色保持透明,外部颜色保持半透明。性能极强,高分辨率下依然能达到10^-5次方。
优点:在调整矩形边框时,性能很高,高速调整边框也不会看出矩形内实际是镶嵌了另外一张图片
不足:如果为矩形添加移动属性,移动时会发现矩形内部图像有十分严重的“残影”现象。
进一步改良:尝试使用了多种防抖动技术,如使用Canvas对象的scan_mark和scan_dragto增量移动(避免coords重定位);以及限制更新频率,效果不佳。参考代码如下(由于效果不达意,且不太好写边界处理,矩形框移动到边界处有显著Bug!):
import tkinter as tk
from PIL import Image, ImageTk
import ctypes
class TransparentOverlayApp(tk.Tk):
alpha = 80 # 透明程度(0 - 255, 越小越透明)
def __init__(self, image_path: str):
super().__init__()
self.image: Image.Image = None
self.rect_pos: list[int, int, int, int] = [0, 0, 0, 0]
self.on_press_pos: tuple[int, int] = [0, 0]
self.local_image_canvas: tk.Canvas = None
self.local_window: int = None # self.canvas.create_window返回的id
self.local_image: int = None # create_image返回的id
self.after_id: int = None # 限制移动频率以防抖动
self.title("仿微信截图区域暗亮比对")
self.load_image(image_path)
self.canvas: tk.Canvas = self.set_basic_canvas()
self.config_basic_canvas()
def load_image(self, image_path) -> None:
self.image = Image.open(image_path)
self.orig_imagetk = ImageTk.PhotoImage(self.image)
self.overlay_imagetk = ImageTk.PhotoImage(self.overlay_image(self.image))
def set_basic_canvas(self) -> tk.Canvas:
canvas = tk.Canvas(self, width=self.image.width, height=self.image.height)
canvas.pack()
return canvas
def config_basic_canvas(self) -> None:
self.orig_imageid = self.canvas.create_image(0, 0, anchor="nw", image=self.orig_imagetk)
self.canvas.bind("<ButtonPress-1>", self.start_overlay)
self.canvas.bind("<ButtonRelease-1>", self.enter_move_mode)
def overlay_image(self, image: Image.Image) -> Image.Image:
# 此处调节暗色区域的透明度
overlay = Image.new(
"RGBA", (self.image.width, self.image.height), (0, 0, 0, self.__class__.alpha)
)
overlayed_image = Image.alpha_composite(image.convert("RGBA"), overlay)
return overlayed_image
def start_overlay(self, event) -> None:
self.canvas.itemconfig(self.orig_imageid, image=self.overlay_imagetk)
self.local_image_canvas = tk.Canvas(self, highlightthickness=2, highlightbackground="#1AAE1A")
self.local_image = self.local_image_canvas.create_image(0, 0, anchor="nw", image=self.orig_imagetk)
self.local_window = self.canvas.create_window(-self.image.width, -self.image.height, anchor="nw", window=self.local_image_canvas)
self.rect_pos = [event.x, event.y, event.x, event.y]
self.canvas.bind("<B1-Motion>", self.update_overlay)
def enter_move_mode(self, _) -> None:
def start_move(event):
self.on_press_pos = (event.x_root, event.y_root)
self.local_image_canvas.scan_mark(event.x_root, event.y_root)
self.canvas.unbind("<ButtonPress-1>")
self.canvas.unbind("<B1-Motion>")
self.canvas.unbind("<ButtonRelease-1>")
self.local_image_canvas.bind("<ButtonPress-1>", start_move)
self.local_image_canvas.bind("<B1-Motion>", self.move_overlay)
def move_overlay(self, event) -> None:
offset_x = event.x_root - self.on_press_pos[0]
offset_y = event.y_root - self.on_press_pos[1]
x1, y1, x2, y2 = self.vaild_rect_pos()
if x1 + offset_x >= 0 and x2 + offset_x <= self.canvas.winfo_width():
x1 += offset_x
x2 += offset_x
if y1 + offset_y >= 0 and y2 + offset_y <= self.canvas.winfo_height():
y1 += offset_y
y2 += offset_y
self.on_press_pos = (event.x_root, event.y_root)
self.rect_pos = [x1, y1, x2, y2]
# self.resize_view()
# self.move_view(event)
self.delayed_move_view(event)
def update_overlay(self, event) -> None:
self.rect_pos[2] = event.x
self.rect_pos[3] = event.y
self.resize_view()
def stop_overlay(self, _) -> None:
self.canvas.unbind("<B1-Motion>")
self.canvas.itemconfig(self.orig_imageid, image=self.orig_imagetk)
self.canvas.delete(self.local_window)
self.local_image_canvas.destroy()
self.local_window = None
self.local_image = None
def resize_view(self) -> None:
x1, y1, x2, y2 = self.vaild_rect_pos()
self.canvas.itemconfig(self.local_window, width=max(x2 - x1, 1), height=max(y2 - y1, 1)) # 新大小
self.local_image_canvas.coords(self.local_image, -x1, -y1)
self.canvas.coords(self.local_window, x1, y1)
def delayed_move_view(self, event) -> None:
if self.after_id is not None:
self.after_cancel(self.after_id)
self.after_id = self.after(3, lambda: self.move_view(event))
def move_view(self, event) -> None:
# 下面的gain = -1表示反方向移动,关键所在!!!
x1, y1, _, _ = self.vaild_rect_pos()
self.canvas.coords(self.local_window, x1, y1)
# 这里应该增加额外的逻辑限制scan_dragto把矩形内的视图移歪
self.local_image_canvas.config(scrollregion=self.local_image_canvas.bbox("all"))
self.local_image_canvas.scan_dragto(event.x_root, event.y_root, gain=-1)
def vaild_rect_pos(self) -> bool:
x1, y1, x2, y2 = self.rect_pos
x1, y1, x2, y2 = min(x1, x2), min(y1, y2), max(x1, x2), max(y1, y2)
return x1, y1, x2, y2
if __name__ == "__main__":
image_path = r"2880x1800.png"
ctypes.windll.shcore.SetProcessDpiAwareness(1)
app = TransparentOverlayApp(image_path)
app.mainloop()
我们还注意到,当用户移动选区时,此时选区的大小是不变的,只有位置在变化。那么何不在此时直接创建一个半透明图层,从中挖出这个和选区大小一样的透明区域的ImageTk对象呢?这样我们就能在移动时只创建一次PhotoImage对象,然后一直动态修改它的位置,性能应该不错?
示例代码如下,不过实测代码效果并不是很好:
import tkinter as tk
from PIL import Image, ImageTk, ImageDraw
import ctypes
import time
class TransparentOverlayApp(tk.Tk):
alpha = 80 # 透明程度(0 - 255, 越小越透明)
def __init__(self, image_path: str):
super().__init__()
self.image: Image.Image = None
self.rect_pos: list[int, int, int, int] = [0, 0, 0, 0]
self.move_pos: list[int, int] = [0, 0]
self.local_image_canvas: tk.Canvas = None
self.local_window: int = None # self.canvas.create_window返回的id
self.local_image: int = None # create_image返回的id
self.move_event: str = None
self.title("仿微信截图区域暗亮比对")
self.load_image(image_path)
self.canvas: tk.Canvas = self.set_basic_canvas()
self.config_basic_canvas()
def load_image(self, image_path) -> None:
self.image = Image.open(image_path)
self.orig_imagetk = ImageTk.PhotoImage(self.image)
def set_basic_canvas(self) -> tk.Canvas:
canvas = tk.Canvas(self, width=self.image.width, height=self.image.height)
canvas.pack()
return canvas
def config_basic_canvas(self) -> None:
self.overlay_imagetk = ImageTk.PhotoImage(self.get_overlay_image())
self.orig_imageid = self.canvas.create_image(0, 0, anchor="nw", image=self.orig_imagetk)
self.canvas.bind("<ButtonPress-1>", self.start_overlay)
self.canvas.bind("<ButtonRelease-1>", self.enter_move_mode)
def get_overlay_image(self, alpha_pos: tuple[int, int, int, int] = None) -> Image.Image:
if alpha_pos is None:
return Image.new("RGBA", (self.image.width, self.image.height), (0, 0, 0, self.__class__.alpha))
else:
x1, y1, x2, y2 = alpha_pos
overlay = Image.new(
"RGBA",
(self.image.width * 2 + (x2 - x1), self.image.height * 2 + (y2 - y1)),
(0, 0, 0, self.__class__.alpha)
)
new_x1 = x1 + (self.image.width - x2)
new_y1 = y1 + (self.image.height - y2)
new_alpha_pos = (new_x1, new_y1, new_x1 + x2 - x1, new_y1 + y2 - y1)
draw = ImageDraw.Draw(overlay)
draw.rectangle(new_alpha_pos, fill=(0, 0, 0, 0), outline="#1AAE1A", width=2)
return overlay
def start_overlay(self, event) -> None:
self.canvas.create_image(0, 0, anchor="nw", image=self.overlay_imagetk, tags="overlay")
self.local_image_canvas = tk.Canvas(self, highlightthickness=2, highlightbackground="#1AAE1A")
self.local_image = self.local_image_canvas.create_image(0, 0, anchor="nw", image=self.orig_imagetk)
self.local_window = self.canvas.create_window(-self.image.width, -self.image.height, anchor="nw", window=self.local_image_canvas)
self.rect_pos = [event.x, event.y, event.x, event.y]
self.canvas.bind("<B1-Motion>", self.update_overlay)
self.bind("<space>", self.stop_overlay)
def update_overlay(self, event) -> None:
self.rect_pos[2] = event.x
self.rect_pos[3] = event.y
self.update_canvas()
def enter_move_mode(self, event) -> None:
self.canvas.unbind("<ButtonPress-1>")
self.canvas.unbind("<ButtonRelease-1>")
self.canvas.unbind("<B1-Motion>")
self.overlay_imagetk = ImageTk.PhotoImage(self.get_overlay_image(self.rect_pos))
self.canvas.itemconfig("overlay", image=self.overlay_imagetk)
self.canvas.coords("overlay", self.rect_pos[2] - self.image.width, self.rect_pos[3] - self.image.height)
self.canvas.delete(self.local_window)
self.canvas.bind("<ButtonPress-1>", self.initilize_move_pos)
self.canvas.bind("<B1-Motion>", self.move_overlay)
def initilize_move_pos(self, event) -> None:
self.move_pos = [event.x_root, event.y_root]
def move_overlay(self, event) -> None:
def _move_overlay():
self.canvas.move("overlay", final_dx, final_dy)
self.move_event = None
offset_x = event.x_root - self.move_pos[0]
offset_y = event.y_root - self.move_pos[1]
final_dx = final_dy = 0
start_x, start_y, end_x, end_y = self.rect_pos
if start_x + offset_x >= 0 and end_x + offset_x <= self.winfo_width():
start_x += offset_x
end_x += offset_x
final_dx = offset_x
if start_y + offset_y >= 0 and end_y + offset_y <= self.winfo_height():
start_y += offset_y
end_y += offset_y
final_dy = offset_y
if not self.move_event:
self.move_event = self.after(0, _move_overlay)
self.rect_pos = [start_x, start_y, end_x, end_y]
self.initilize_move_pos(event)
def stop_overlay(self, _) -> None:
self.canvas.unbind("<B1-Motion>")
self.unbind("<space>")
self.canvas.itemconfig(self.orig_imageid, image=self.orig_imagetk)
self.canvas.delete(self.local_window)
self.canvas.delete("overlay")
self.local_image_canvas.destroy()
self.local_window = None
self.local_image = None
self.config_basic_canvas()
def update_canvas(self) -> None:
x1, y1, x2, y2 = self.rect_pos
# 确保坐标是有效的
x1, y1, x2, y2 = min(x1, x2), min(y1, y2), max(x1, x2), max(y1, y2)
self.canvas.itemconfig(self.local_window, width=max(x2 - x1, 1), height=max(y2 - y1, 1)) # 新大小
self.canvas.coords(self.local_window, x1, y1)
self.local_image_canvas.coords(self.local_image, -x1, -y1)
if __name__ == "__main__":
# 自动替换为你的文件路径
image_path = r"test.png"
ctypes.windll.shcore.SetProcessDpiAwareness(1)
app = TransparentOverlayApp(image_path)
app.mainloop()
旧代码参考
思路:反复使用PhotoImage创建半透明图层,并从中抠出全透明图层。同时尝试通过差分法减少ImageDraw绘制区域以优化性能。
优点:移动矩形框时不会出现“残影”;
不足:性能很差(每次更新需10^-2s),无论是调整还是移动矩形框,都有十分显著的卡顿感。
过程反思:在写代码的过程中未经过单独的性能测试,错误的将优化方向指向了ImageDraw(尝试差分法,发现没什么效果) 。后续通过测试发现性能瓶颈在PhotoImage的构建后,尝试使用PhotoImage的paste方法,但发现该方法与Image对象的paste方法不同,实际上并不能指定粘贴区域(被注释掉的地方)。
import tkinter as tk
from PIL import Image, ImageDraw, ImageTk
import ctypes
from time import time, perf_counter
class TransparentOverlayApp:
def __init__(self, root):
self.root = root
self.root.title("Transparent Overlay Demo")
# 加载图片
self.image = Image.open(r"D:\Users\pbl\Desktop\2879x1795.png") # 替换为您的图片路径
self.photo = ImageTk.PhotoImage(self.image)
# 创建 Canvas
self.canvas = tk.Canvas(root, width=self.image.width, height=self.image.height)
self.canvas.pack()
# 在 Canvas 上显示图片
self.canvas.create_image(0, 0, anchor="nw", image=self.photo)
# 绑定鼠标事件
self.canvas.bind("<ButtonPress-1>", self.start_overlay)
self.canvas.bind("<ButtonRelease-1>", self.stop_overlay)
# 初始化遮罩
self.overlay = None
self.rect = None
self.rect_id = None
self.prev_coords = None
def start_overlay(self, event):
# 创建半透明遮罩
self.overlay = Image.new("RGBA", (self.image.width, self.image.height), (0, 0, 0, 128))
self.draw = ImageDraw.Draw(self.overlay)
self.rect = [event.x, event.y, event.x, event.y] # 初始化矩形区域
self.rect_id = self.canvas.create_rectangle(*self.rect, outline="red", tag="overlay")
self.update_canvas()
# 绑定鼠标移动事件
self.canvas.bind("<B1-Motion>", self.update_overlay)
def update_overlay(self, event):
# 更新矩形区域
self.rect[2] = event.x
self.rect[3] = event.y
self.canvas.coords(self.rect_id, *self.rect)
self.update_canvas()
def stop_overlay(self, _):
# 销毁遮罩
self.overlay = None
self.prev_coords = None
self.canvas.delete(self.rect_id)
self.canvas.delete(self.image_id)
self.canvas.imagetk = None
def update_canvas(self):
current_coords = self.canvas.bbox(self.rect_id)
if self.prev_coords:
# 计算交集
intersection = self.calculate_intersection(self.prev_coords, current_coords)
# 计算差集
diff_a_list = self.calculate_difference(self.prev_coords, intersection)
diff_b_list = self.calculate_difference(current_coords, intersection)
for diff_a in diff_a_list:
self.update_difference(diff_a, (0, 0, 0, 128)) # 恢复上一次区域
for diff_b in diff_b_list:
self.update_difference(diff_b, (0, 0, 0, 0)) # 更新当前区域
self.imagetk = ImageTk.PhotoImage(self.overlay)
self.canvas.itemconfigure(self.image_id, image=self.imagetk)
else:
self.draw.rectangle(current_coords, fill=(0, 0, 0, 0))
self.imagetk = ImageTk.PhotoImage(self.overlay)
self.image_id = self.canvas.create_image(0, 0, anchor="nw", image=self.imagetk)
self.prev_coords = current_coords
def calculate_intersection(self, rect1, rect2):
# 计算两个矩形的交集
left = max(rect1[0], rect2[0])
top = max(rect1[1], rect2[1])
right = min(rect1[2], rect2[2])
bottom = min(rect1[3], rect2[3])
return (left, top, right, bottom)
def calculate_difference(self, rect1, rect2):
start = perf_counter()
intersection = self.calculate_intersection(rect1, rect2)
if not intersection or intersection[0] >= intersection[2] or intersection[1] >= intersection[3]:
# 如果没有交集,差集就是整个矩形
return [rect1]
diff = []
if rect1[0] < intersection[0]:
diff.append((rect1[0], rect1[1], intersection[0], rect1[3]))
if rect1[2] > intersection[2]:
diff.append((intersection[2], rect1[1], rect1[2], rect1[3]))
if rect1[1] < intersection[1]:
diff.append((rect1[0], rect1[1], rect1[2], intersection[1]))
if rect1[3] > intersection[3]:
diff.append((rect1[0], intersection[3], rect1[2], rect1[3]))
return diff
def update_difference(self, diff: tuple[int, int, int, int], color: tuple[int, int, int, int]):
if diff:
self.draw.rectangle(diff, fill=color)
# width, height = diff[2] - diff[0], diff[3] - diff[1]
# self.imagetk.paste(Image.new("RGBA", (width, height), color), diff)
# 主程序
if __name__ == "__main__":
ctypes.windll.shcore.SetProcessDpiAwareness(1)
root = tk.Tk()
app = TransparentOverlayApp(root)
root.mainloop()
不过,我最后还是找到了一种方法可以有效解决移动方框时内部的移动残影感问题且拥有较好的性能。具体实现详看我的截图小工具的github项目:
Python制作的截图小工具-Lightscreenshot
在源代码中的Widgets.py文件中, 里面的AdjustableRect类中的move_rect方法就实现了这一功能,具体的技术叫做“离屏重绘”,真正意义上让tkinter强制将正确的图像重绘出来,这样就完全没有移动的残影感了。