from functools import update_wrapper from PIL import Image from PIL import ImageEnhance from PIL import ImageFilter import click @click.group(chain=True) def cli(): """This script processes a bunch of images through pillow in a unix pipe. One commands feeds into the next. Example: \b imagepipe open -i example01.jpg resize -w 128 display imagepipe open -i example02.jpg blur save """ @cli.result_callback() def process_commands(processors): """This result callback is invoked with an iterable of all the chained subcommands. As in this example each subcommand returns a function we can chain them together to feed one into the other, similar to how a pipe on unix works. """ # Start with an empty iterable. stream = () # Pipe it through all stream processors. for processor in processors: stream = processor(stream) # Evaluate the stream and throw away the items. for _ in stream: pass def processor(f): """Helper decorator to rewrite a function so that it returns another function from it. """ def new_func(*args, **kwargs): def processor(stream): return f(stream, *args, **kwargs) return processor return update_wrapper(new_func, f) def generator(f): """Similar to the :func:`processor` but passes through old values unchanged and does not pass through the values as parameter. """ @processor def new_func(stream, *args, **kwargs): yield from stream yield from f(*args, **kwargs) return update_wrapper(new_func, f) def copy_filename(new, old): new.filename = old.filename return new @cli.command("open") @click.option( "-i", "--image", "images", type=click.Path(), multiple=True, help="The image file to open.", ) @generator def open_cmd(images): """Loads one or multiple images for processing. The input parameter can be specified multiple times to load more than one image. """ for image in images: try: click.echo(f"Opening '{image}'") if image == "-": img = Image.open(click.get_binary_stdin()) img.filename = "-" else: img = Image.open(image) yield img except Exception as e: click.echo(f"Could not open image '{image}': {e}", err=True) @cli.command("save") @click.option( "--filename", default="processed-{:04}.png", type=click.Path(), help="The format for the filename.", show_default=True, ) @processor def save_cmd(images, filename): """Saves all processed images to a series of files.""" for idx, image in enumerate(images): try: fn = filename.format(idx + 1) click.echo(f"Saving '{image.filename}' as '{fn}'") yield image.save(fn) except Exception as e: click.echo(f"Could not save image '{image.filename}': {e}", err=True) @cli.command("display") @processor def display_cmd(images): """Opens all images in an image viewer.""" for image in images: click.echo(f"Displaying '{image.filename}'") image.show() yield image @cli.command("resize") @click.option("-w", "--width", type=int, help="The new width of the image.") @click.option("-h", "--height", type=int, help="The new height of the image.") @processor def resize_cmd(images, width, height): """Resizes an image by fitting it into the box without changing the aspect ratio. """ for image in images: w, h = (width or image.size[0], height or image.size[1]) click.echo(f"Resizing '{image.filename}' to {w}x{h}") image.thumbnail((w, h)) yield image @cli.command("crop") @click.option( "-b", "--border", type=int, help="Crop the image from all sides by this amount." ) @processor def crop_cmd(images, border): """Crops an image from all edges.""" for image in images: box = [0, 0, image.size[0], image.size[1]] if border is not None: for idx, val in enumerate(box): box[idx] = max(0, val - border) click.echo(f"Cropping '{image.filename}' by {border}px") yield copy_filename(image.crop(box), image) else: yield image def convert_rotation(ctx, param, value): if value is None: return value = value.lower() if value in ("90", "r", "right"): return (Image.ROTATE_90, 90) if value in ("180", "-180"): return (Image.ROTATE_180, 180) if value in ("-90", "270", "l", "left"): return (Image.ROTATE_270, 270) raise click.BadParameter(f"invalid rotation '{value}'") def convert_flip(ctx, param, value): if value is None: return value = value.lower() if value in ("lr", "leftright"): return (Image.FLIP_LEFT_RIGHT, "left to right") if value in ("tb", "topbottom", "upsidedown", "ud"): return (Image.FLIP_LEFT_RIGHT, "top to bottom") raise click.BadParameter(f"invalid flip '{value}'") @cli.command("transpose") @click.option( "-r", "--rotate", callback=convert_rotation, help="Rotates the image (in degrees)" ) @click.option("-f", "--flip", callback=convert_flip, help="Flips the image [LR / TB]") @processor def transpose_cmd(images, rotate, flip): """Transposes an image by either rotating or flipping it.""" for image in images: if rotate is not None: mode, degrees = rotate click.echo(f"Rotate '{image.filename}' by {degrees}deg") image = copy_filename(image.transpose(mode), image) if flip is not None: mode, direction = flip click.echo(f"Flip '{image.filename}' {direction}") image = copy_filename(image.transpose(mode), image) yield image @cli.command("blur") @click.option("-r", "--radius", default=2, show_default=True, help="The blur radius.") @processor def blur_cmd(images, radius): """Applies gaussian blur.""" blur = ImageFilter.GaussianBlur(radius) for image in images: click.echo(f"Blurring '{image.filename}' by {radius}px") yield copy_filename(image.filter(blur), image) @cli.command("smoothen") @click.option( "-i", "--iterations", default=1, show_default=True, help="How many iterations of the smoothen filter to run.", ) @processor def smoothen_cmd(images, iterations): """Applies a smoothening filter.""" for image in images: click.echo( f"Smoothening {image.filename!r} {iterations}" f" time{'s' if iterations != 1 else ''}" ) for _ in range(iterations): image = copy_filename(image.filter(ImageFilter.BLUR), image) yield image @cli.command("emboss") @processor def emboss_cmd(images): """Embosses an image.""" for image in images: click.echo(f"Embossing '{image.filename}'") yield copy_filename(image.filter(ImageFilter.EMBOSS), image) @cli.command("sharpen") @click.option( "-f", "--factor", default=2.0, help="Sharpens the image.", show_default=True ) @processor def sharpen_cmd(images, factor): """Sharpens an image.""" for image in images: click.echo(f"Sharpen '{image.filename}' by {factor}") enhancer = ImageEnhance.Sharpness(image) yield copy_filename(enhancer.enhance(max(1.0, factor)), image) @cli.command("paste") @click.option("-l", "--left", default=0, help="Offset from left.") @click.option("-r", "--right", default=0, help="Offset from right.") @processor def paste_cmd(images, left, right): """Pastes the second image on the first image and leaves the rest unchanged. """ imageiter = iter(images) image = next(imageiter, None) to_paste = next(imageiter, None) if to_paste is None: if image is not None: yield image return click.echo(f"Paste '{to_paste.filename}' on '{image.filename}'") mask = None if to_paste.mode == "RGBA" or "transparency" in to_paste.info: mask = to_paste image.paste(to_paste, (left, right), mask) image.filename += f"+{to_paste.filename}" yield image yield from imageiter