4
\$\begingroup\$

This script first looks through a specified folder path for image files that have a given extension and removes any periods that might be in those file names. It also adds those sanitized file names to a list. It then converts the files in that list to the desired image type. Finally, if the dimensions of any of the images in the folder are either 1920x1080 or 1920x1200, it creates a copy of that file in the appropriate subfolder.

from PIL import Image
import os
from os.path import exists
import shutil
import pathlib
from time import sleep

img_folder = r'C:\Users\StephenWilliams\OneDrive - CamLink Services\Wallpapers'
target_extension = '.webp'
desired_extension = '.png'
image_list = []

os.chdir(img_folder)


def main():
    fname_sanitation()
    convert_files()
    copy_files_per_dim()


# Removes periods from file names
def fname_sanitation():
    for file in os.listdir(img_folder):
        if pathlib.Path(file).suffix == target_extension:
            file_name = file[:-len(target_extension)]
            new_file_name = file_name.replace('.', '') + target_extension
            os.rename(file, new_file_name)
            image_list.append(new_file_name)
    sleep(5)


# Converts .webp files to .png
def convert_files():
    conversion_count = 0
    for file in image_list:
        file_name, file_extension = file.split('.')
        im = Image.open(file).convert("RGB")
        im.save(f'{file_name}{desired_extension}', desired_extension.replace('.', ''))
        os.remove(file)
        conversion_count += 1
    print(f'File Conversion Successful: {conversion_count} File(s) Converted')


# Copies image files with dimensions of 1920x1080 or 1920x1200 to dimension-based folders
def copy_files_per_dim():
    folder_1080 = img_folder + r'\1920x1080'
    folder_1200 = img_folder + r'\1920x1200'
    copy_count = 0

    if not os.path.exists(folder_1080):
        os.makedirs(folder_1080)

    if not os.path.exists(folder_1200):
        os.makedirs(folder_1200)

    dim_list = [(1920, 1080), (1920, 1200)]
    for file in os.listdir(img_folder):
        if os.path.isfile(file):
            im = Image.open(file)
            if im.size in dim_list:
                if im.size == (1920, 1080):
                    if not exists(f'{folder_1080}/{file}'):
                        shutil.copy(file, folder_1080)
                        copy_count += 1
                else:
                    if not exists(f'{folder_1200}/{file}'):
                        shutil.copy(file, folder_1200)
                        copy_count += 1

    print(f'File Copy Process Complete: {copy_count} File(s) Copied')


main()
\$\endgroup\$

1 Answer 1

5
\$\begingroup\$

These are several comments regarding you question:

  • Use function parameters instead of global variables.
  • Use pathlib instead of os.
  • Configuration (img_folder, target_extension and desired_extension) should not be contained within code. That makes it necessary to modify the code if configuration changes. Instead, you could extract it to a file or command line arguments.
  • Do not sleep. As a rule of thumb, if you are using the sleep function, you are probably doing something wrong. In this case, it is adding an unnecessary wait for the user.
  • Core logic/business logic should not handle data presentation (print in copy_files_per_dim)
  • Call main with a if __name__ == "__main__" clause. This allows your code to be reused elsewhere.
  • I would make the deletion of the previously existent files optional. The user of the script may not expect to have their files deleted.
  • Use docstrings instead of comments for your functions.
  • Use type hints
  • Use logging instead of print

This is the code I propose:

import logging
from typing import Iterable, NamedTuple
from pathlib import Path

from PIL import Image

# Logging configuration could be extracted to a file, but it is kept here for simplicity of the example
logging.basicConfig(format="[%(levelname)s] - %(message)s", level=logging.INFO)

logger = logging.getLogger(__name__)


class Dimension(NamedTuple):
    width: int
    height: int


OTHER_DIMENSION = Dimension(-1, -1)


def convert_images(
    src: Path,
    target_extension: str,
    desired_extension: str,
    dimensions: Iterable[Dimension],
    delete_previous: bool = False,
) -> None:
    """Converts images to the specified extension and classifies them by size

    :param src: The directory in which to look for images
    :param target_extension: The extension of the images to convert
    :param desired_extension: The desired format of the new images
    :param dimensions: The dimensions on which to classify the images. Could be
        empty. If an image has a size not specified in dimensions, it will be
        moved to a directory named `other`
    :param delete_previous: Whether to delete images after conversion
    """
    dimensions_map = _create_dimension_map(src, dimensions)
    num_successfully_converted_images = 0
    for image_to_convert in src.glob(f"*{target_extension}"):
        converted_image = convert_image(image_to_convert, desired_extension)
        move_according_to_dimension(converted_image, dimensions_map)
        if delete_previous:
            image_to_convert.unlink(missing_ok=True)
        num_successfully_converted_images += 1
    logger.info("Successfully converted %d files", num_successfully_converted_images)


def _create_dimension_map(
    src: Path, dimensions: Iterable[Dimension]
) -> dict[Dimension, Path]:
    """Creates the dimensions map"""
    dimensions_map = {
        dimension: src / f"{dimension.width}x{dimension.height}"
        for dimension in dimensions
    }
    dimensions_map[OTHER_DIMENSION] = src / "other"

    for dimension_path in dimensions_map.values():
        dimension_path.mkdir(parents=True, exist_ok=True)

    return dimensions_map



def convert_image(image_path: Path, desired_extension: str) -> Path:
    """Converts a file to a desired format

    :param image_path: The path to the image to be converted
    :param desired_extension: The desired format. E.g. ".png"
    :return: The path of the newly created image in the desired format
    """
    logger.debug("Converting %s", image_path)
    image = Image.open(image_path).convert("RGB")
    new_file = image_path.with_suffix(desired_extension)
    image.save(new_file.parent / new_file.stem, new_file.suffix)
    logger.debug("Converted %s to %s", image_path, new_file)
    return new_file


def move_according_to_dimension(
    image_path: Path, dimension_map: dict[Dimension, Path]
) -> Path:
    """Moves `image_path` to the directory indicated by `dimension_map`

    :param image_path: The path of the image to move
    :param dimension_map: A map of dimensions. Should contain a default
        dimension `OTHER_DIMENSION`
    :return: The path of the moved file
    """
    image_size = Image.open(image_path).size
    dest_dir = dimension_map.get(image_size, dimension_map[OTHER_DIMENSION])
    dest_path = dest_dir / image_path.name
    image_path.rename(dest_path)
    logger.debug("Moved %s into %s", image_path, dest_path)
    return dest_path


if __name__ == "__main__":
    import argparse

    parser = argparse.ArgumentParser(
        description="Converts images to a desired format",
        formatter_class=argparse.ArgumentDefaultsHelpFormatter,
    )

    parser.add_argument(
        "--src",
        type=Path,
        default=Path(__file__).absolute().parent,
        help="Directory from which to process images. Defaults to this file's directory",
    )

    parser.add_argument(
        "--target-format",
        type=str,
        default=".webp",
        help="The extension of the images to process",
    )

    parser.add_argument(
        "--desired-format",
        type=str,
        default=".png",
        help="The format to which to convert images",
    )

    parser.add_argument(
        "--delete-previous",
        action="store_true",
        help="If set, it will delete processed images",
    )

    def dimension_parser(dimension_str: str) -> Dimension:
        try:
            width, height = dimension_str.split("x")
            return Dimension(int(width), int(height))
        except ValueError:
            raise argparse.ArgumentTypeError(
                f"Invalid dimension {dimension_str}. Expected format is WIDTHxHEIGHT. (E.g. 1920x1080)"
            )

    parser.add_argument(
        "--dimensions",
        nargs="+",
        default=(Dimension(1920, 1080), Dimension(1920, 1200)),
        type=dimension_parser,
        help="The dimensions for which on which to classify images",
    )

    args = parser.parse_args()
    convert_images(
        src=args.src,
        target_extension=args.target_format,
        desired_extension=args.desired_format,
        dimensions=args.dimensions,
        delete_previous=args.delete_previous,
    )

Now this can be used as:

> python convert.py --src "C:/Users/StephenWilliams/OneDrive - CamLink Services/Wallpapers"
[INFO] - Successfully converted 5 files

It also provides a helpful message and default argument values:

> python converty.py --help
usage: convert.py [-h] [--src SRC] [--target-format TARGET_FORMAT] [--desired-format DESIRED_FORMAT] [--delete-previous] [--dimensions DIMENSIONS [DIMENSIONS ...]]

Converts images to a desired format

options:
  -h, --help            show this help message and exit
  --src SRC             Directory from which to process images. Defaults to this file's directory (default: ...)
  --target-format TARGET_FORMAT
                        The extension of the images to process (default: .webp)
  --desired-format DESIRED_FORMAT
                        The format to which to convert images (default: .png)
  --delete-previous     If set, it will delete processed images (default: False)
  --dimensions DIMENSIONS [DIMENSIONS ...]
                        The dimensions for which on which to classify images (default: (Dimension(width=1920, height=1080), Dimension(width=1920, height=1200)))
\$\endgroup\$
6
  • 1
    \$\begingroup\$ Consider replacing your comprehension [file for file in src.iterdir() if file.suffix == file_extension] with a glob(). \$\endgroup\$
    – Reinderien
    Jul 3, 2022 at 13:50
  • 2
    \$\begingroup\$ image_to_convert.unlink(missing_ok=True) should probably not have the missing_ok, since we really do expect that the file is there. If it isn't, something very strange has happened. \$\endgroup\$
    – Reinderien
    Jul 3, 2022 at 13:52
  • 2
    \$\begingroup\$ dimension_map[image_size] if image_size in dimension_map else dimension_map[OTHER_DIMENSION] should just be dimension_map.get(image_size, dimension_map[OTHER_DIMENSION]). \$\endgroup\$
    – Reinderien
    Jul 3, 2022 at 13:55
  • 1
    \$\begingroup\$ Otherwise quite good answer. \$\endgroup\$
    – Reinderien
    Jul 3, 2022 at 13:55
  • 2
    \$\begingroup\$ Thank you for reviewing the answer, @Reinderien. Good catchs with the glob and get with default values. I forgot about them \$\endgroup\$ Jul 3, 2022 at 13:59

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Not the answer you're looking for? Browse other questions tagged or ask your own question.