Last active
April 17, 2024 16:40
-
-
Save thegamecracks/0133b9dbf417d65b3533b27b37b5bd3f to your computer and use it in GitHub Desktop.
A basic command-line program for compositing two images using Pillow
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
""" | |
Dependencies: | |
Pillow>=10.3.0 | |
usage: composite.py [-h] [-s SCALE] [-o OFFSET] image | |
Create a composite of the given image and display it. | |
The window can be clicked to toggle it as an overlay. | |
You can also right click it to save the image. | |
positional arguments: | |
image The image to be pasted | |
options: | |
-h, --help show this help message and exit | |
-s SCALE, --scale SCALE | |
Scale image by this factor (default: 1) | |
-o OFFSET, --offset OFFSET | |
Offset image by X,Y pixels (default: 0,0) | |
""" | |
import argparse | |
import io | |
import sys | |
import textwrap | |
from tkinter import Event, Label, Menu, PhotoImage, Tk, filedialog | |
try: | |
from PIL import Image, ImageOps | |
except ImportError as e: | |
sys.exit("Pillow>=10.3.0 must be installed") | |
BACKGROUND = Image.open("path/to/image.png") | |
"""The image to use as the background. This dictates the final image size.""" | |
OFFSET = (0, 0) | |
"""The default center coordinates where the image will be pasted.""" | |
SIZE = (1, 1) | |
"""The default size of the image in pixels.""" | |
def main() -> None: | |
description = textwrap.dedent( | |
""" | |
Create a composite of the given image and display it. | |
The window can be clicked to toggle it as an overlay. | |
You can also right click it to save the image. | |
""" | |
) | |
parser = argparse.ArgumentParser( | |
description=description, | |
formatter_class=argparse.RawDescriptionHelpFormatter, | |
) | |
parser.add_argument( | |
"-s", | |
"--scale", | |
default="1", | |
help="Scale image by this factor (default: 1)", | |
type=float, | |
) | |
parser.add_argument( | |
"-o", | |
"--offset", | |
default="0,0", | |
help="Offset image by X,Y pixels (default: 0,0)", | |
type=parse_offset, | |
) | |
parser.add_argument( | |
"image", | |
help="The image to be pasted", | |
type=Image.open, | |
) | |
args = parser.parse_args() | |
scale: float = args.scale | |
offset: tuple[int, int] = args.offset | |
image: Image.Image = args.image | |
image = create_image_composite(image, scale=scale, offset=offset) | |
enable_windows_dpi_awareness() | |
app = TkImageShow(image) | |
app.mainloop() | |
def parse_offset(s: str) -> tuple[int, int]: | |
x, _, y = s.partition(",") | |
x, y = x.strip(), y.strip() | |
x, y = int(x), int(y) | |
return x, y | |
def enable_windows_dpi_awareness() -> None: | |
if sys.platform == "win32": | |
from ctypes import windll | |
windll.shcore.SetProcessDpiAwareness(2) | |
def create_image_composite( | |
image: Image.Image, | |
scale: float = 1, | |
offset: tuple[int, int] = (0, 0), | |
) -> Image.Image: | |
size = (int(SIZE[0] * scale), int(SIZE[1] * scale)) | |
image = ImageOps.contain(image, size, method=Image.Resampling.LANCZOS) | |
image = image.convert("RGBA") | |
base = Image.new("RGBA", BACKGROUND.size) | |
base.paste(BACKGROUND, (0, 0)) | |
pos = ( | |
OFFSET[0] + offset[0] - image.size[0] // 2, | |
OFFSET[1] + offset[1] - image.size[1] // 2, | |
) | |
base.alpha_composite(image, pos) | |
return base | |
class TkImageShow(Tk): | |
def __init__(self, image: Image.Image) -> None: | |
super().__init__() | |
self.image = image | |
self.geometry("{}x{}".format(*image.size)) | |
self.option_add("*tearOff", False) | |
self.grid_columnconfigure(0, weight=1) | |
self.grid_rowconfigure(0, weight=1) | |
self.label = ImageLabel(self, image=image) | |
self.label.bind("<1>", self.toggle_overlay) | |
self.label.grid(sticky="nesw") | |
self.context = Menu(self) | |
self.context.add_command(label="Save to file...", command=self.save_to_disk) | |
self.bind("<3>", lambda event: self.context.post(event.x_root, event.y_root)) | |
def toggle_overlay(self, event: Event) -> None: | |
not_overlayed = not self.overrideredirect() | |
self.overrideredirect(not_overlayed) | |
self.attributes("-topmost", not_overlayed) | |
def save_to_disk(self) -> None: | |
file = filedialog.asksaveasfile( | |
"wb", | |
filetypes=[("All files", "*")], | |
initialfile="image.png", | |
) | |
if file is None: | |
return | |
with file: | |
self.image.save(file) | |
class ImageLabel(Label): | |
def __init__(self, *args, image: Image.Image, **kwargs) -> None: | |
self.image = image | |
self._photo = self._create_photo_image(image.size) | |
self._photo_size = image.size | |
super().__init__(*args, image=self._photo, **kwargs) | |
self.bind("<Configure>", self.__on_configure) | |
def _create_photo_image(self, size: tuple[int, int]) -> PhotoImage: | |
resized = ImageOps.cover(self.image, size, method=Image.Resampling.BILINEAR) | |
with io.BytesIO() as f: | |
resized.save(f, format="ppm") | |
return PhotoImage(data=f.getvalue()) | |
def __on_configure(self, event: Event) -> None: | |
window_size = (event.width, event.height) | |
if self._photo_size != window_size: | |
self._photo = self._create_photo_image(window_size) | |
self._photo_size = window_size | |
self.configure(image=self._photo) | |
if __name__ == "__main__": | |
main() |
Author
thegamecracks
commented
Apr 15, 2024
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment