2014-10-16 20:40:34 +02:00
|
|
|
from functools import update_wrapper
|
2020-07-21 08:23:42 +02:00
|
|
|
|
|
|
|
from PIL import Image
|
|
|
|
from PIL import ImageEnhance
|
|
|
|
from PIL import ImageFilter
|
|
|
|
|
|
|
|
import click
|
2014-10-16 20:40:34 +02:00
|
|
|
|
|
|
|
|
|
|
|
@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
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
2021-10-10 03:31:57 +02:00
|
|
|
@cli.result_callback()
|
2014-10-16 20:40:34 +02:00
|
|
|
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.
|
|
|
|
"""
|
2020-07-21 08:23:42 +02:00
|
|
|
|
2014-10-16 20:40:34 +02:00
|
|
|
def new_func(*args, **kwargs):
|
|
|
|
def processor(stream):
|
|
|
|
return f(stream, *args, **kwargs)
|
2020-07-21 08:23:42 +02:00
|
|
|
|
2014-10-16 20:40:34 +02:00
|
|
|
return processor
|
2020-07-21 08:23:42 +02:00
|
|
|
|
2014-10-16 20:40:34 +02:00
|
|
|
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.
|
|
|
|
"""
|
2020-07-21 08:23:42 +02:00
|
|
|
|
2014-10-16 20:40:34 +02:00
|
|
|
@processor
|
|
|
|
def new_func(stream, *args, **kwargs):
|
2021-10-10 03:31:57 +02:00
|
|
|
yield from stream
|
|
|
|
yield from f(*args, **kwargs)
|
2020-07-21 08:23:42 +02:00
|
|
|
|
2014-10-16 20:40:34 +02:00
|
|
|
return update_wrapper(new_func, f)
|
|
|
|
|
|
|
|
|
|
|
|
def copy_filename(new, old):
|
|
|
|
new.filename = old.filename
|
|
|
|
return new
|
|
|
|
|
|
|
|
|
2020-07-21 08:23:42 +02:00
|
|
|
@cli.command("open")
|
|
|
|
@click.option(
|
|
|
|
"-i",
|
|
|
|
"--image",
|
|
|
|
"images",
|
|
|
|
type=click.Path(),
|
|
|
|
multiple=True,
|
|
|
|
help="The image file to open.",
|
|
|
|
)
|
2014-10-16 20:40:34 +02:00
|
|
|
@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:
|
2021-10-10 03:31:57 +02:00
|
|
|
click.echo(f"Opening '{image}'")
|
2020-07-21 08:23:42 +02:00
|
|
|
if image == "-":
|
2014-10-16 20:40:34 +02:00
|
|
|
img = Image.open(click.get_binary_stdin())
|
2020-07-21 08:23:42 +02:00
|
|
|
img.filename = "-"
|
2014-10-16 20:40:34 +02:00
|
|
|
else:
|
|
|
|
img = Image.open(image)
|
|
|
|
yield img
|
|
|
|
except Exception as e:
|
2021-10-10 03:31:57 +02:00
|
|
|
click.echo(f"Could not open image '{image}': {e}", err=True)
|
2014-10-16 20:40:34 +02:00
|
|
|
|
|
|
|
|
2020-07-21 08:23:42 +02:00
|
|
|
@cli.command("save")
|
|
|
|
@click.option(
|
|
|
|
"--filename",
|
|
|
|
default="processed-{:04}.png",
|
|
|
|
type=click.Path(),
|
|
|
|
help="The format for the filename.",
|
|
|
|
show_default=True,
|
|
|
|
)
|
2014-10-16 20:40:34 +02:00
|
|
|
@processor
|
|
|
|
def save_cmd(images, filename):
|
|
|
|
"""Saves all processed images to a series of files."""
|
|
|
|
for idx, image in enumerate(images):
|
|
|
|
try:
|
2020-07-21 08:23:42 +02:00
|
|
|
fn = filename.format(idx + 1)
|
2021-10-10 03:31:57 +02:00
|
|
|
click.echo(f"Saving '{image.filename}' as '{fn}'")
|
2014-10-16 20:40:34 +02:00
|
|
|
yield image.save(fn)
|
|
|
|
except Exception as e:
|
2021-10-10 03:31:57 +02:00
|
|
|
click.echo(f"Could not save image '{image.filename}': {e}", err=True)
|
2014-10-16 20:40:34 +02:00
|
|
|
|
|
|
|
|
2020-07-21 08:23:42 +02:00
|
|
|
@cli.command("display")
|
2014-10-16 20:40:34 +02:00
|
|
|
@processor
|
|
|
|
def display_cmd(images):
|
|
|
|
"""Opens all images in an image viewer."""
|
|
|
|
for image in images:
|
2021-10-10 03:31:57 +02:00
|
|
|
click.echo(f"Displaying '{image.filename}'")
|
2014-10-16 20:40:34 +02:00
|
|
|
image.show()
|
|
|
|
yield image
|
|
|
|
|
|
|
|
|
2020-07-21 08:23:42 +02:00
|
|
|
@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.")
|
2014-10-16 20:40:34 +02:00
|
|
|
@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])
|
2021-10-10 03:31:57 +02:00
|
|
|
click.echo(f"Resizing '{image.filename}' to {w}x{h}")
|
2014-10-16 20:40:34 +02:00
|
|
|
image.thumbnail((w, h))
|
|
|
|
yield image
|
|
|
|
|
|
|
|
|
2020-07-21 08:23:42 +02:00
|
|
|
@cli.command("crop")
|
|
|
|
@click.option(
|
|
|
|
"-b", "--border", type=int, help="Crop the image from all sides by this amount."
|
|
|
|
)
|
2014-10-16 20:40:34 +02:00
|
|
|
@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)
|
2021-10-10 03:31:57 +02:00
|
|
|
click.echo(f"Cropping '{image.filename}' by {border}px")
|
2014-10-16 20:40:34 +02:00
|
|
|
yield copy_filename(image.crop(box), image)
|
|
|
|
else:
|
|
|
|
yield image
|
|
|
|
|
|
|
|
|
|
|
|
def convert_rotation(ctx, param, value):
|
|
|
|
if value is None:
|
|
|
|
return
|
|
|
|
value = value.lower()
|
2020-07-21 08:23:42 +02:00
|
|
|
if value in ("90", "r", "right"):
|
2014-10-16 20:40:34 +02:00
|
|
|
return (Image.ROTATE_90, 90)
|
2020-07-21 08:23:42 +02:00
|
|
|
if value in ("180", "-180"):
|
2014-10-16 20:40:34 +02:00
|
|
|
return (Image.ROTATE_180, 180)
|
2020-07-21 08:23:42 +02:00
|
|
|
if value in ("-90", "270", "l", "left"):
|
2014-10-16 20:40:34 +02:00
|
|
|
return (Image.ROTATE_270, 270)
|
2021-10-10 03:31:57 +02:00
|
|
|
raise click.BadParameter(f"invalid rotation '{value}'")
|
2014-10-16 20:40:34 +02:00
|
|
|
|
|
|
|
|
|
|
|
def convert_flip(ctx, param, value):
|
|
|
|
if value is None:
|
|
|
|
return
|
|
|
|
value = value.lower()
|
2020-07-21 08:23:42 +02:00
|
|
|
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")
|
2021-10-10 03:31:57 +02:00
|
|
|
raise click.BadParameter(f"invalid flip '{value}'")
|
2020-07-21 08:23:42 +02:00
|
|
|
|
|
|
|
|
|
|
|
@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]")
|
2014-10-16 20:40:34 +02:00
|
|
|
@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
|
2021-10-10 03:31:57 +02:00
|
|
|
click.echo(f"Rotate '{image.filename}' by {degrees}deg")
|
2014-10-16 20:40:34 +02:00
|
|
|
image = copy_filename(image.transpose(mode), image)
|
|
|
|
if flip is not None:
|
|
|
|
mode, direction = flip
|
2021-10-10 03:31:57 +02:00
|
|
|
click.echo(f"Flip '{image.filename}' {direction}")
|
2014-10-16 20:40:34 +02:00
|
|
|
image = copy_filename(image.transpose(mode), image)
|
|
|
|
yield image
|
|
|
|
|
|
|
|
|
2020-07-21 08:23:42 +02:00
|
|
|
@cli.command("blur")
|
|
|
|
@click.option("-r", "--radius", default=2, show_default=True, help="The blur radius.")
|
2014-10-16 20:40:34 +02:00
|
|
|
@processor
|
|
|
|
def blur_cmd(images, radius):
|
|
|
|
"""Applies gaussian blur."""
|
|
|
|
blur = ImageFilter.GaussianBlur(radius)
|
|
|
|
for image in images:
|
2021-10-10 03:31:57 +02:00
|
|
|
click.echo(f"Blurring '{image.filename}' by {radius}px")
|
2014-10-16 20:40:34 +02:00
|
|
|
yield copy_filename(image.filter(blur), image)
|
|
|
|
|
|
|
|
|
2020-07-21 08:23:42 +02:00
|
|
|
@cli.command("smoothen")
|
|
|
|
@click.option(
|
|
|
|
"-i",
|
|
|
|
"--iterations",
|
|
|
|
default=1,
|
|
|
|
show_default=True,
|
|
|
|
help="How many iterations of the smoothen filter to run.",
|
|
|
|
)
|
2014-10-16 20:40:34 +02:00
|
|
|
@processor
|
|
|
|
def smoothen_cmd(images, iterations):
|
|
|
|
"""Applies a smoothening filter."""
|
|
|
|
for image in images:
|
2020-07-21 08:23:42 +02:00
|
|
|
click.echo(
|
2021-10-10 03:31:57 +02:00
|
|
|
f"Smoothening {image.filename!r} {iterations}"
|
|
|
|
f" time{'s' if iterations != 1 else ''}"
|
2020-07-21 08:23:42 +02:00
|
|
|
)
|
|
|
|
for _ in range(iterations):
|
2014-10-16 20:40:34 +02:00
|
|
|
image = copy_filename(image.filter(ImageFilter.BLUR), image)
|
|
|
|
yield image
|
|
|
|
|
|
|
|
|
2020-07-21 08:23:42 +02:00
|
|
|
@cli.command("emboss")
|
2014-10-16 20:40:34 +02:00
|
|
|
@processor
|
|
|
|
def emboss_cmd(images):
|
|
|
|
"""Embosses an image."""
|
|
|
|
for image in images:
|
2021-10-10 03:31:57 +02:00
|
|
|
click.echo(f"Embossing '{image.filename}'")
|
2014-10-16 20:40:34 +02:00
|
|
|
yield copy_filename(image.filter(ImageFilter.EMBOSS), image)
|
|
|
|
|
|
|
|
|
2020-07-21 08:23:42 +02:00
|
|
|
@cli.command("sharpen")
|
|
|
|
@click.option(
|
|
|
|
"-f", "--factor", default=2.0, help="Sharpens the image.", show_default=True
|
|
|
|
)
|
2014-10-16 20:40:34 +02:00
|
|
|
@processor
|
|
|
|
def sharpen_cmd(images, factor):
|
|
|
|
"""Sharpens an image."""
|
|
|
|
for image in images:
|
2021-10-10 03:31:57 +02:00
|
|
|
click.echo(f"Sharpen '{image.filename}' by {factor}")
|
2014-10-16 20:40:34 +02:00
|
|
|
enhancer = ImageEnhance.Sharpness(image)
|
|
|
|
yield copy_filename(enhancer.enhance(max(1.0, factor)), image)
|
|
|
|
|
|
|
|
|
2020-07-21 08:23:42 +02:00
|
|
|
@cli.command("paste")
|
|
|
|
@click.option("-l", "--left", default=0, help="Offset from left.")
|
|
|
|
@click.option("-r", "--right", default=0, help="Offset from right.")
|
2014-10-16 20:40:34 +02:00
|
|
|
@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
|
|
|
|
|
2021-10-10 03:31:57 +02:00
|
|
|
click.echo(f"Paste '{to_paste.filename}' on '{image.filename}'")
|
2014-10-16 20:40:34 +02:00
|
|
|
mask = None
|
2020-07-21 08:23:42 +02:00
|
|
|
if to_paste.mode == "RGBA" or "transparency" in to_paste.info:
|
2014-10-16 20:40:34 +02:00
|
|
|
mask = to_paste
|
|
|
|
image.paste(to_paste, (left, right), mask)
|
2021-10-10 03:31:57 +02:00
|
|
|
image.filename += f"+{to_paste.filename}"
|
2014-10-16 20:40:34 +02:00
|
|
|
yield image
|
|
|
|
|
2021-10-10 03:31:57 +02:00
|
|
|
yield from imageiter
|