Source code for batch_crop.batch_crop

# This file is part of batch_crop: A Python utility for batch cropping images
# Copyright (C) 2018  U8N WXD <cs.temporary@icloud.com>
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program.  If not, see <http://www.gnu.org/licenses/>.


"""Crop files in bulk, maintaining the crop region's relative position

"""

import os
from os.path import isfile, join
import configparser
from datetime import datetime
import tkinter as tk
from tkinter.filedialog import askopenfilename, asksaveasfilename
from tkinter import messagebox
from typing import Tuple, List

import rawpy
from PIL import Image, ImageTk


[docs]def display_block(title: str, content: str) -> None: """Display a block of text in a new window The window is of fixed size height=30 and width=100 and has a scrollbar for the text. Args: title: Title of displayed window content: Text to display in window Returns: None """ def close(): second.destroy() second = tk.Tk() second.wm_title(title) scroll_bar = tk.Scrollbar(second) text = tk.Text(second, height=30, width=100, wrap='word') close_button = tk.Button(second, text='Close', command=close) scroll_bar.pack(side=tk.RIGHT, fill=tk.Y) text.pack(side=tk.LEFT, fill=tk.Y) close_button.pack(side=tk.BOTTOM) scroll_bar.config(command=text.yview) text.config(yscrollcommand=scroll_bar.set) text.insert(tk.END, content) second.mainloop()
# pylint: disable=too-many-instance-attributes, too-many-ancestors
[docs]class BatchCropper(tk.Frame): """Tkinter GUI app for performing batch crops of images Attributes: scale_factor (int): Factor by which original image dimensions are multiplied to yield the displayed image dimensions image_tk (ImageTk.PhotoImage): Image to display to the user for crop region selection to_crop (List[str]): The paths of all images to crop start_x (float): x-coordinate of one corner of the selected region start_y (float): y-coordinate of one corner of the selected region end_x (float): x-coordinate of the opposing corner of the selection end_y (float): y-coordinate of the opposing corner of the selection rect (tk.Canvas): Displayed rectangle that demarcates the selected region to crop orig_size(Tuple[float, float]): The original size of the loaded image, stored as ``(width, height)`` canvas (tk.Canvas): Where the image is displayed to the user button_load_image (tk.Button): button_load_coors (tk.Button): button_save_coors (tk.Button): button_submit (tk.Button): button_about (tk.Button): button_license (tk.Button): button_quit (tk.Button): label_instructions (tk.Label): label_dir (tk.Label): Displays the directory of images to crop label_dir_label (tk.Label): Displays the label for the directory label_ext (tk.Label): Displays the extension of images to crop label_ext_label (tk.Label): Displays the label for the extension """ # INSPIRATION: fhdrsdg https://stackoverflow.com/a/29797178 def __init__(self, window: tk.Tk) -> None: """Setup attributes and build main user interface dialog Args: window: Root window on which to build the main UI """ tk.Frame.__init__(self, window) self.window = window # Initialize instance fields for later self.scale_factor = 1 # type: float self.image_tk = None # type: ImageTk.PhotoImage self.to_crop = [] # type: List[str] self.start_x = -1 # type: float self.start_y = -1 # type: float self.end_x = -1 # type: float self.end_y = -1 # type: float self.rect = None # type: ignore self.orig_size = -1, -1 # type: Tuple[float, float] self.canvas = tk.Canvas(self.window, width=500, height=500) self.canvas.pack() self.canvas.bind("<ButtonPress-1>", self.callback_mouse_down) self.canvas.bind("<B1-Motion>", self.callback_mouse_move) self.canvas.bind("<ButtonRelease-1>", self.callback_mouse_up) self.button_load_image = tk.Button(self.window, text="Load Image", command=self.callback_load_image) self.button_load_coors = tk.Button(self.window, text="Load Coordinates", command=self.callback_load_coors) self.button_save_coors = tk.Button(self.window, text="Save Coordinates", command=self.callback_save_coors) self.button_submit = tk.Button(self.window, text="Crop All Matching Images", command=self.callback_crop) self.button_about = tk.Button(self.window, text="About", command=BatchCropper.callback_about) self.button_license = tk.Button(self.window, text="License", command=BatchCropper.callback_license) self.button_quit = tk.Button(self.window, text="Quit", command=BatchCropper.callback_quit) self.label_instructions = tk.Label(self.window, text="Select an Image") self.label_dir = tk.Label(self.window, text="") self.label_dir_label = tk.Label(self.window, text="Directory of Images to Crop: ") self.label_ext = tk.Label(self.window, text="") self.label_ext_label = tk.Label(self.window, text="Extension of Images to Crop: ") # Arrange UI elements self.label_instructions.grid(row=0, column=0, columnspan=2) self.label_dir_label.grid(row=1, column=0) self.label_dir.grid(row=1, column=1) self.label_ext_label.grid(row=2, column=0) self.label_ext.grid(row=2, column=1) self.button_load_image.grid(row=3, column=0) self.button_load_coors.grid(row=4, column=0) self.button_save_coors.grid(row=5, column=0) self.button_submit.grid(row=6, column=0) self.button_about.grid(row=7, column=0) self.button_license.grid(row=8, column=0) self.button_quit.grid(row=9, column=0) self.canvas.grid(row=3, column=1, rowspan=7)
[docs] def callback_load_image(self) -> None: """Load an image of the user's choice Meant to be triggered by tkinter when user selects a button. The user is allowed to choose an image, which then is displayed. All images of the same extension and in the same directory, including the displayed image, have their paths stored in :py:attr:`to_crop`. The instruction text is updated to tell the user to select a region. Returns: None """ chosen = askopenfilename() dir_path = os.path.dirname(chosen) self.label_dir.configure(text=dir_path) _, extension = os.path.splitext(chosen) extension = extension.lower() self.label_ext.configure(text=extension) items = os.listdir(dir_path) files = [item for item in items if isfile(join(dir_path, item))] crop_names = [file for file in files if file.lower().endswith(extension)] self.to_crop = [join(dir_path, name) for name in crop_names] image_raw = open_image(chosen) self.orig_size = image_raw.size self.scale_factor = get_scale_factor(500, image_raw) image_resized = scale_image(self.scale_factor, image_raw) self.image_tk = self.display_image(image_resized) self.label_instructions.configure(text="Select Region to Crop")
[docs] def display_image(self, image: Image) -> ImageTk.PhotoImage: """Create and display an image for tkinter from a provided image Args: image: The image to process and display Returns: Image that was loaded onto the tkinter canvas """ image_tk = ImageTk.PhotoImage(image) self.canvas.create_image(0, 0, anchor="nw", image=image_tk) return image_tk
[docs] def get_coors_ratios(self) -> Tuple[float, float, float, float]: """Get ratios that represent the coordinates of the current region For definitions of coordinates and ratios, see :doc:`units` Returns: A box_ratio that represents the current region """ coors = self.start_x, self.start_y, self.end_x, self.end_y width, height = self.orig_size disp_size = (width * self.scale_factor, height * self.scale_factor) return coors_to_ratios(disp_size, coors)
[docs] def set_coors_ratios(self, box_ratio: Tuple[float, float, float, float]) \ -> None: """Set the coordinates of the selected region from ratios For definitions of coordinates and ratios, see :doc:`units` This method accepts ratios and uses them to set the selection region to the proper displayed coordinates as if the user had selected the region. Args: box_ratio: The ``box_ratio`` to use Returns: None """ width = self.orig_size[0] * self.scale_factor height = self.orig_size[1] * self.scale_factor size = (width, height) coors = ratios_to_coors(size, box_ratio) self.start_x, self.start_y, self.end_x, self.end_y = coors
[docs] def callback_save_coors(self) -> None: """Save coordinates of the currently selected region to a file The user is shown a dialog to select where to save the generated INI file. This coordinates can be later loaded using :py:meth:`BatchCropper.callback_load_coors`. The coordinates are actually saved as a ``box_ratio``, which is generated by :py:meth:`BatchCropper.get_coors_ratios`. The file is created and saved by :py:meth:`save_ratios_to_file`. Error dialogs are displayed if no image is loaded or if no region is selected. Returns: None """ if len(self.to_crop) == 0: # pylint: disable=len-as-condition messagebox.showerror("Error", "Please load an image first.") return if self.end_x < 0 or self.end_y < 0: messagebox.showerror("Error", "Please select a region first.") return box_ratio = self.get_coors_ratios() path = asksaveasfilename(title="Save Coordinates File", defaultextension=".ini", initialdir=os.path.dirname(self.to_crop[0])) save_ratios_to_file(box_ratio, path)
[docs] def callback_load_coors(self) -> None: """Load coordinates for selected region from INI file The user is shown a dialog to choose the file from which coordinates are loaded. The INI file should be created using the :py:meth:`BatchCropper.callback_save_coors` method. The coordinates specified in the file are used to create a region that is stored and displayed as if the user had selected it. Error dialogs are displayed if no image is loaded or if the configuration file cannot be parsed. The configuration file is read with :py:meth:`get_ratios_from_file`, which yields a ``box_ratio`` (see :doc:`units`) that is then loaded using :py:meth:`BatchCropper.set_coors_ratios`. Returns: None """ if len(self.to_crop) == 0: # pylint: disable=len-as-condition messagebox.showerror("Error", "Please load an image first.") return path = askopenfilename(title="Select Coordinates File", filetypes=[("INI", "*.ini")], initialdir=os.path.dirname(self.to_crop[0])) try: ratios = get_ratios_from_file(path) except KeyError: messagebox.showerror("Error", "'{}' could not be parsed". format(path)) return self.set_coors_ratios(ratios) self.replace_rect(self.start_x, self.start_y) self.resize_rect(self.start_x, self.start_y, self.end_x, self.end_y)
[docs] def callback_mouse_down(self, event) -> None: """Start drawing out a rectangle The rectangle is started using :py:meth:`BatchCropper.replace_rect`. This callback is meant to be bound using Tkinter to the mouse move event. Tkinter will then pass the needed ``event`` parameter as it calls this method whenever the mouse moves. Args: event: The event from Tkinter that has attributes ``.x`` and ``.y`` that hold the coordinates of the cursor when mouse released Returns: None """ self.start_x = event.x self.start_y = event.y self.end_x = -1 self.end_y = -1 self.replace_rect(self.start_x, self.start_y)
[docs] def replace_rect(self, x: float, y: float) -> None: """Create a new rectangle The created rectangle will have identical start and end coordinates. Args: x: x-coordinate for both start and end corners of rectangle y: y-coordinate for both start and end corners of rectangle Returns: None """ self.canvas.delete(self.rect) self.rect = self.canvas.create_rectangle(x, y, x, y, outline="red")
[docs] def callback_mouse_move(self, event) -> None: """Expand the displayed selected region to follow the cursor This allows the user to drag out the rectangle. The rectangle is only changed if the mouse button is down (checked by member variables :py:attr:`end_x` and :py:attr:`end_y` being ``None`` if button down). This callback is meant to be bound using Tkinter to the mouse move event. Tkinter will then pass the needed ``event`` parameter as it calls this method whenever the mouse moves. Args: event: The event from Tkinter that has attributes ``.x`` and ``.y`` that hold the coordinates of the cursor when mouse released Returns: None """ cur_x = event.x cur_y = event.y if self.end_x == -1 and self.end_y == -1: self.resize_rect(self.start_x, self.start_y, cur_x, cur_y)
[docs] @staticmethod def callback_quit() -> None: """Exit with code ``0`` Returns: None """ exit(0)
[docs] @staticmethod def callback_about() -> None: """Display the project's about text as stored in :file:about.txt See :py:meth:`display_block` for the details of how the text is displayed. Returns: None """ with open("about.txt", "r") as f: about_text = f.read() display_block("About", about_text)
[docs] @staticmethod def callback_license() -> None: """Display the project's license as stored in :file:LICENSE.txt See :py:meth:`display_block` for the details of how the text is displayed. Returns: None """ with open("LICENSE.txt", "r") as f: license_text = f.read() display_block("License", license_text)
[docs] def resize_rect(self, x1: float, y1: float, x2: float, y2: float) -> None: """Change coordinates of region selection rectangle The region selection rectangle is the red rectangle that represents the region to be cropped Args: x1: x-coordinate of first corner y1: y-coordinate of first corner x2: x-coordinate of opposite corner y2: y-coordinate of opposite corner Returns: None """ self.canvas.coords(self.rect, x1, y1, x2, y2)
[docs] def callback_mouse_up(self, event) -> None: """Save event coordinates as end coordinates and update instructions Store the x and y coordinates of ``event`` to the ``end_x`` and ``end_y`` instance variables. This callback is meant to be bound using Tkinter to the mouse button up event. Tkinter will then pass the needed ``event`` parameter as it calls this method whenever the mouse button is released. Args: event: The event from Tkinter that has attributes ``.x`` and ``.y`` that hold the coordinates of the cursor when mouse released Returns: None """ self.end_x = event.x self.end_y = event.y self.label_instructions.configure(text="Re-select Region or Crop All")
[docs] def callback_crop(self) -> None: """Trigger the cropping of all images Checks if a region is selected, then triggers :py:meth:`BatchCropper.crop_all_files`. Returns: None """ if self.end_x is None or self.end_y is None: messagebox.showerror("Error", "Please select a region to crop.") else: self.crop_all_files()
[docs] def crop_all_files(self) -> None: """Crop all files at the paths in :py:attr:`to_crop` Validates that the user has selected a region. No validation is performed on :py:attr:`to_crop`. The user is asked to confirm, skip, or abort before any file is over-written. Each file is cropped using :py:meth:`crop_file` Returns: None """ for path in self.to_crop: new_path = path + "_cropped.jpg" if os.path.exists(new_path): message = "The file '{}' already exists. Overwrite with new " \ "crop? Select 'Cancel' to abort, 'No' to skip, or " \ "'Yes' to overwrite." message = message.format(new_path) choice = messagebox.askyesnocancel("Overwrite Warning", message) if choice is None: return if choice: crop_file(self.get_coors_ratios(), path, new_path) else: crop_file(self.get_coors_ratios(), path, new_path)
[docs]def crop_file(box_ratio: Tuple[float, float, float, float], in_path: str, out_path: str) -> None: """Save a copy of an image cropped to a specified region Crops the image at ``in_path`` to the same relative region as the user selected on the template image. For example, if the selected region takes up the middle ninth (in a 3x3 grid of equivalent rectangles) of the image, the crop will be the middle ninth of the image at ``path``, even if the two images have different dimensions. No validation is performed on ``in_path``. The cropped image is formatted as a JPEG and saved to ``out_path``. Any existing file at ``out_path`` may be overwritten. The cropped image is created using :py:meth:`crop_image`. Args: box_ratio: A ``box_ratio`` (See :doc:`units`) that describes the region to crop in_path: The path of the image to crop out_path: The path of the file to save the cropped image to Returns: ``True`` if cropping should continue, ``False`` otherwise. """ to_crop = open_image(in_path) cropped = crop_image(box_ratio, to_crop) cropped.save(out_path, "jpeg")
[docs]def crop_image(box_ratio: Tuple[float, float, float, float], image: Image): """Generate a copy of an image cropped to a specified region Args: box_ratio: A ``box_ratio`` (See :doc:`units`) that defines the region to crop image: The image to crop Returns: The cropped image """ box_coor = ratios_to_coors(image.size, box_ratio) box = coor_to_box(box_coor) cropped = image.crop(box) return cropped
[docs]def scale_image(scale_factor: float, image_raw: Image) -> Image: """Scale the provided image to fit within a 500x500 box The image's shape is not changed, the largest dimension is just forced to be 500. The factor by which the image is scaled is stored as the :py:attr:`scale_factor` attribute. Args: scale_factor: The factor by which the image's width and height are re-sized image_raw: The image to re-size Returns: The re-sized image """ width, height = image_raw.size new_width = int(width * scale_factor) new_height = int(height * scale_factor) return image_raw.resize((new_width, new_height))
[docs]def get_scale_factor(max_dimen: float, image_raw: Image) -> float: """Get the factor by which to scale ``image_raw`` based on ``max_dimen`` Args: max_dimen: What to scale the largest dimension of the image to image_raw: The image to scale Returns: The scaled image """ width, height = image_raw.size return max_dimen / max(width, height)
[docs]def coor_to_box(coors: Tuple[float, float, float, float])\ -> Tuple[float, float, float, float]: """Convert start and end coors into left, upper, right, and lower bounds This is useful for converting between the Tkinter concept of start and end coordinates and the ``Image.crop(...)`` concept of bounds. >>> coors = (5, 3, 1, 2) >>> coor_to_box(coors) (1, 2, 5, 3) Args: coors: The ``box_coor`` to convert to a ``box`` Returns: A Tuple of bounds of the form .. code-block:: python left_bound, upper_bound, right_bound, lower_bound that represents the region to crop. The bounds represent values on the same coordinate system as normal. """ start_x, start_y, end_x, end_y = coors left = min(start_x, end_x) right = max(start_x, end_x) upper = min(start_y, end_y) lower = max(start_y, end_y) return left, upper, right, lower
[docs]def coors_to_ratios(image_size: Tuple[float, float], coors: Tuple[float, float, float, float])\ -> Tuple[float, float, float, float]: """Convert a ``box_coor`` to a ``box_ratio`` >>> image_size = 10, 100 >>> coors = 1, 2, 5, 4 >>> coors_to_ratios(image_size, coors) (0.1, 0.02, 0.5, 0.04) Args: image_size: The size of the image that is the context for ``coors`` coors: ``box_coor`` to convert Returns: The ``box_ratio`` """ x1, y1, x2, y2 = coors width, height = image_size x1, x2 = tuple([val / width for val in (x1, x2)]) y1, y2 = tuple([val / height for val in (y1, y2)]) return x1, y1, x2, y2
[docs]def ratios_to_coors(image_size: Tuple[float, float], ratios: Tuple[float, float, float, float]) -> \ Tuple[float, float, float, float]: """Convert a ``box_ratio`` to a ``box_coor`` Args: image_size: Size of image that is the context for ``box_coor`` ratios: The ``box_ratio`` to convert Returns: The ``box_coor`` """ x1, y1, x2, y2 = ratios width, height = image_size x1, x2 = tuple([ratio * width for ratio in (x1, x2)]) y1, y2 = tuple([ratio * height for ratio in (y1, y2)]) return x1, y1, x2, y2
[docs]def gen_ratios_config(box_ratio: Tuple[float, float, float, float]) \ -> configparser.ConfigParser: """Create the configuration that stores the provided box The configuration is stored under the section ``crop-coordinates`` in the following format INI, given ``box_ratio = (x1, y1, x2, y2)``: .. code-block:: ini [crop-coordinates] start_x = {x1} start_y = {y2} end_x = {x2} end_y = {y2} substituting ``{...}`` for the value of the variable in braces. Args: box_ratio: The box_ratio to generate a configuration for Returns: The configuration """ start_x, start_y, end_x, end_y = box_ratio config = configparser.ConfigParser() config["crop-coordinates"] = {"start_x": str(start_x), "start_y": str(start_y), "end_x": str(end_x), "end_y": str(end_y)} return config
[docs]def save_ratios_to_file(box_ratio: Tuple[float, float, float, float], path: str) -> None: """Save the configuration for the ``box_ratio`` to the specified INI file The configuration is generated by :py:meth:`gen_coors_config`. Args: box_ratio: The ``box_ratio`` to store in the file path: The path to the INI file to store the configuration in. The file should be empty. Returns: None """ config = gen_ratios_config(box_ratio) header = ["This file stores the coordinates of a selection made with", "batch_crop.py, which is hosted at", "https://github.com/U8NWXD/batch_crop", "File Created: {}".format(datetime.now())] with open(path, "w") as configfile: configfile.writelines(["# " + line + "\n" for line in header]) config.write(configfile)
[docs]def get_ratios_from_file(path: str) -> Tuple[float, float, float, float]: """Get a ``box_ratio`` from a configuration file The configuration file should have been generated by :py:meth:`save_ratios_to_file`. The configuration in the file is converted to a ``box_ratio`` by :py:meth:`get_ratios_from_config`. Args: path: Path to configuration INI file Returns: ``box_ratio`` that was described by the file """ config = configparser.ConfigParser() config.read(path) return get_ratios_from_config(config)
[docs]def get_ratios_from_config(config: configparser.ConfigParser) \ -> Tuple[float, float, float, float]: """Get a ``box_ratio`` from a configuration Args: config: INI configuration describing the ``box_ratio`` to read Returns: ``box_ratio`` described by the configuration """ coor_conf = config["crop-coordinates"] # Remember these are ratios start_x = coor_conf.getfloat("start_x") start_y = coor_conf.getfloat("start_y") end_x = coor_conf.getfloat("end_x") end_y = coor_conf.getfloat("end_y") return start_x, start_y, end_x, end_y
[docs]def open_image(path: str) -> Image: """Attempt to open an image, using a method appropriate for the format Supported image types: RAW / ARW and those supported by Pillow. Errors are not handled. Format is determined by file extension. Args: path: Path to the image. Must correctly point to a supported image type. Returns: A Pillow Image object loaded from ``path`` """ _, ext = os.path.splitext(path) ext = ext.lower() if ext in (".arw", ".raw"): image = open_raw_image(path) else: image = Image.open(path) return image
[docs]def open_raw_image(path: str) -> Image: """Open RAW-formatted image using ``rawpy`` No format checking or error handling is performed. Args: path: Path to the image. Must be correct. Returns: A Pillow Image object representing the image at ``path`` """ with rawpy.imread(path) as raw: mat = raw.postprocess() return Image.fromarray(mat)
if __name__ == "__main__": MASTER = tk.Tk() APP = BatchCropper(MASTER) APP.master.title("batch_crop") # type: ignore MASTER.mainloop()