diff --git a/README.md b/README.md index 48f7eab..b8d58f3 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ 2) Copy all pictures to be modified in a folder -> See [example/](example/) +> See [example](example) 3) Run this python script on that folder with a list of names of persons to be blurred @@ -22,7 +22,7 @@ > > This results in the following picture: > -> ![](example/unsplash_blurred.jpg) +> ![](example/unsplash[blurred].jpg) ## Credits diff --git a/area.py b/area.py new file mode 100644 index 0000000..4722f60 --- /dev/null +++ b/area.py @@ -0,0 +1,32 @@ +from __future__ import annotations + +import exif + + +class NormalizedRectangle: + """ + x, y, width and height are normalized: Their values are in the range [0, 1]. + """ + + x: float + y: float + width: float + height: float + + def __init__(self, x, y, width, height): + self.x = x + self.y = y + self.width = width + self.height = height + + @staticmethod + def of_exif_image_region(region: exif.ExifImageRegion) -> NormalizedRectangle: + if region.area_unit == 'normalized': + return NormalizedRectangle( + x=region.rectangle[0], + y=region.rectangle[1], + width=region.rectangle[2], + height=region.rectangle[3] + ) + else: + raise Exception() diff --git a/blur.py b/blur.py deleted file mode 100644 index 75aeb96..0000000 --- a/blur.py +++ /dev/null @@ -1,103 +0,0 @@ -from __future__ import annotations - -from pathlib import Path -from typing import List - -from PIL import ImageDraw -from PIL import ImageFilter -from PIL import Image - -import exif - - -class NormalizedRectangle: - """ - x, y, width and height are normalized: Their values are in the range [0, 1]. - """ - - x: float - y: float - width: float - height: float - - def __init__(self, x, y, width, height): - self.x = x - self.y = y - self.width = width - self.height = height - - @staticmethod - def of_exif_image_region(region: exif.ExifImageRegion) -> NormalizedRectangle: - if region.area_unit == 'normalized': - return NormalizedRectangle(x=region.rectangle[0], y=region.rectangle[1], - width=region.rectangle[2], height=region.rectangle[3]) - else: - raise Exception - - -def blur_rectangle0(image_src: exif.Image, region: exif.ExifImageRegion, image_dst: Path = None) -> Path: - if region.area_unit == 'normalized': - return blur_rectangle1(image_src, - normalized_rectangles=[NormalizedRectangle.of_exif_image_region(region=region)], - image_dst=image_dst) - else: - raise Exception(f'Unknown area_unit: {region.area_unit}') - - -def blur_rectangle1(image_src: exif.Image, normalized_rectangles: List[NormalizedRectangle], image_dst: Path = None) -> Path: - return blur_rectangle2(image_src.get_image_file(), - normalized_rectangles, - image_dst=image_dst) - - -def blur_rectangle2(image_src: Path, normalized_rectangles: List[NormalizedRectangle], image_dst: Path = None) -> Path: - if len(normalized_rectangles) == 0: - raise Exception('No rectangles to blur') - - # Open an image - im = Image.open(image_src) - - # Create mask - mask = Image.new('L', im.size, 0) - draw = ImageDraw.Draw(mask) - - # For each rectangle: Draw a white rectangle to the mask - for normalized_rectangle in normalized_rectangles: - # Calculate top left and lower right corners of rectangle - im_width, im_height = im.size - x1 = im_width * normalized_rectangle.x - y1 = im_height * normalized_rectangle.y - x2 = x1 + im_width * normalized_rectangle.width - y2 = y1 + im_height * normalized_rectangle.height - - draw.rectangle([(x1, y1), (x2, y2)], fill=255) - - # Save the mask - mask.save('mask.png') - - # Blur image - blurred = im.filter(ImageFilter.GaussianBlur(52)) - - # Paste blurred region and save result - im.paste(blurred, mask=mask) - - # Save image - if image_dst is None: - image_dst = get_image_dst(image_src) - im.save(image_dst) - - return image_dst - - -def get_image_dst(image: Path): - return image.parent.joinpath(f'{image.stem}{stem_suffix()}{image.suffix}') - - -def stem_suffix(): - """ - Modified images will be saved with a different filename. - This suffix will be added to their stem. - """ - - # return ' [blurred]' - return '_blurred' diff --git a/example/unsplash[blurred].jpg b/example/unsplash[blurred].jpg new file mode 100644 index 0000000..4db9576 Binary files /dev/null and b/example/unsplash[blurred].jpg differ diff --git a/example/unsplash_blurred.jpg b/example/unsplash_blurred.jpg deleted file mode 100644 index 6c51f6b..0000000 Binary files a/example/unsplash_blurred.jpg and /dev/null differ diff --git a/exif.py b/exif.py index 95c2476..707d4cb 100644 --- a/exif.py +++ b/exif.py @@ -1,57 +1,6 @@ from pathlib import Path -from typing import List, AnyStr -import exec - - -class Image: - """ - An image may be just a single file (e.g. "IMG_001.RAF") or - multiple files ("IMG_002.JPG" (image-data) and - "IMG_002.JPG.xmp" (additional metadata)). - """ - - files: List[Path] - - def __init__(self, image_file: Path): - if image_file.is_file(): - self.files = [image_file] - image_file_name = image_file.name # file.name ==> file basename - for sibling in image_file.parent.iterdir(): - sibling_stem = sibling.stem # file.stem ==> file basename without extension - if sibling_stem == image_file_name: - self.files += [sibling] - if len(self.files) == 0: - raise Exception - else: - raise Exception(f'{image_file}') - - def get_image_file(self): - return self.files[0] - - def get_xmp_file(self): - """ - :return: The sidecar xmp file, if it exists. Otherwise None is returned. - """ - - for file in self.files: - file_extension = file.suffix[1:] - if file_extension.lower() == 'xmp': - return file - return None - - def get_metadata_file(self): - """ - If a sidecar xmp file exists, it is preferred over the image file itself. - - :return: A file containing the image metadata. - """ - metadata = self.get_xmp_file() - if metadata is None: - metadata = self.get_image_file() - return metadata - - def __str__(self): - return f'Image: {self.__dict__}' +from image_files import ImageFiles +from subprocess_util import execute_save class ExifImageRegion: @@ -60,15 +9,15 @@ class ExifImageRegion: For example a face tag. """ - image: Image + image_files: ImageFiles name: str # 'John' r_type: str # 'Face' area_unit: str # 'normalized' - rectangle: List[float] # [ x-coordinate, y-coordinate, width, height ] + rectangle: list[float] # [ x-coordinate, y-coordinate, width, height ] - def __init__(self, image: Image, name, r_type, area_unit, rectangle): - self.image = image + def __init__(self, image_files: ImageFiles, name, r_type, area_unit, rectangle): + self.image_files = image_files self.name = name self.r_type = r_type self.area_unit = area_unit @@ -78,13 +27,13 @@ class ExifImageRegion: return f'ExifImageRegion: {self.__dict__}' -def get_exif_image_regions(image: Image) -> List[ExifImageRegion]: +def get_exif_image_regions(image: ImageFiles) -> list[ExifImageRegion]: img_metadata_file: Path = image.get_metadata_file() - names_str = exec.execute_save(['exiftool', '-RegionName', str(img_metadata_file)]) - r_types_str = exec.execute_save(['exiftool', '-RegionType', str(img_metadata_file)]) - area_units_str = exec.execute_save(['exiftool', '-RegionAreaUnit', str(img_metadata_file)]) - rectangles_str = exec.execute_save(['exiftool', '-RegionRectangle', str(img_metadata_file)]) + names_str = execute_save(['exiftool', '-RegionName', str(img_metadata_file)]) + r_types_str = execute_save(['exiftool', '-RegionType', str(img_metadata_file)]) + area_units_str = execute_save(['exiftool', '-RegionAreaUnit', str(img_metadata_file)]) + rectangles_str = execute_save(['exiftool', '-RegionRectangle', str(img_metadata_file)]) if len(names_str) == len(r_types_str) == len(area_units_str) == len(rectangles_str) == 0: # there are no tagged areas on this image @@ -103,5 +52,5 @@ def get_exif_image_regions(image: Image) -> List[ExifImageRegion]: rectangles[i] += [None] rectangles[i][j] = float(rectangles_tmp[i * 4 + j]) - return [ExifImageRegion(image=image, name=name, r_type=r_type, area_unit=area_unit, rectangle=rectangle) for + return [ExifImageRegion(image_files=image, name=name, r_type=r_type, area_unit=area_unit, rectangle=rectangle) for name, r_type, area_unit, rectangle in zip(names, r_types, area_units, rectangles)] diff --git a/image_files.py b/image_files.py new file mode 100644 index 0000000..dcd1afe --- /dev/null +++ b/image_files.py @@ -0,0 +1,51 @@ +from pathlib import Path + + +class ImageFiles: + """ + An image may be just a single file (e.g. "IMG_001.RAF") or + multiple files ("IMG_002.JPG" (image-data) and + "IMG_002.JPG.xmp" (additional metadata)). + """ + + files: list[Path] + + def __init__(self, image_file: Path): + if image_file.is_file(): + self.files = [image_file] + image_file_name = image_file.name # file.name ==> file basename + for sibling in image_file.parent.iterdir(): + sibling_stem = sibling.stem # file.stem ==> file basename without extension + if sibling_stem == image_file_name: + self.files += [sibling] + if len(self.files) == 0: + raise Exception + else: + raise Exception(f'Not a file: {image_file}') + + def get_image_file(self): + return self.files[0] + + def get_xmp_file(self): + """ + :return: The sidecar xmp file, if it exists. Otherwise, `None` is returned. + """ + for file in self.files: + file_extension = file.suffix[1:] + if file_extension.lower() == 'xmp': + return file + return None + + def get_metadata_file(self): + """ + If a sidecar xmp file exists, it is preferred over the image file itself. + + :return: A file containing the image metadata. + """ + metadata = self.get_xmp_file() + if metadata is None: + metadata = self.get_image_file() + return metadata + + def __str__(self): + return f'Image: {self.__dict__}' diff --git a/image_manipulation.py b/image_manipulation.py new file mode 100644 index 0000000..a21ebb6 --- /dev/null +++ b/image_manipulation.py @@ -0,0 +1,175 @@ +from __future__ import annotations + +from pathlib import Path +from typing import List + +from PIL import ImageDraw +from PIL import ImageFilter +from PIL import Image +from PIL import ImageFont +from PIL import ImageOps + +import exif +from area import NormalizedRectangle +from subprocess_util import execute_save + + +def open_image(src: Path | exif.ImageFiles, exif_rotation: bool = True) -> Image.Image: + """ + Open image and rotate according to EXIF metadata. + + :param src: + :param exif_rotation: + :return: + """ + if isinstance(src, exif.ImageFiles): + src = src.get_image_file() + + # Open an image. + image = Image.open(src) + + if exif_rotation: + # Rotate according to EXIF orientation. + image = ImageOps.exif_transpose(image) + + return image + + +def blur(image: Image.Image, + areas: List[exif.ExifImageRegion | NormalizedRectangle]) -> Image.Image: + """ + :param image: + :param areas: Areas to blur on the given image. + :return: Reference to the modified image. + """ + if areas is None or len(areas) == 0: + return image + + areas_ = [] + for area in areas: + if isinstance(area, NormalizedRectangle): + areas_.append(area) + if isinstance(area, exif.ExifImageRegion): + areas_.append(NormalizedRectangle.of_exif_image_region(area)) + else: + raise Exception() + areas = areas_ + + # Create mask. + mask = Image.new('L', image.size, 0) + draw = ImageDraw.Draw(mask) + + # For each rectangle: Draw a white rectangle to the mask. + for area in areas: + # Calculate top left and lower right corners of rectangle. + im_width, im_height = image.size + x1 = im_width * area.x + y1 = im_height * area.y + x2 = x1 + im_width * area.width + y2 = y1 + im_height * area.height + + draw.rectangle(((x1, y1), (x2, y2)), fill=255) + + # Save the mask. + mask_path = Path('mask.png') + mask.save(mask_path) + + # Blur radius. + # Between 50 and 250. Depending on how many pixels are inside the face-rectangle. + # 50 for small faces on low-res images. 250 for a close-up on a high-resolution image. + radius = 200 + # Blur image. + blurred = image.filter(ImageFilter.GaussianBlur(radius)) + + # Paste blurred region and save result. + image.paste(blurred, mask=mask) + + mask_path.unlink(missing_ok=False) + return image + + +def resize(image: Image.Image, max_resolution: int = 2048) -> Image.Image: + if max_resolution is None or max_resolution < 0: + return image + + actual_resolution: int = max(image.size[0], image.size[1]) + factor: float = max_resolution / actual_resolution + return image.resize((round(factor * image.size[0]), round(factor * image.size[1]))) + + +def add_text(image: Image.Image, text: str = None) -> Image.Image: + if text is None or len(text) == 0: + return image + + x = 80 + y = image.size[1] - 80 + + font = ImageFont.truetype("/usr/share/fonts/noto/NotoSans-Regular.ttf", 64) + d = ImageDraw.Draw(image) + + # anchor - Quick reference: https://pillow.readthedocs.io/en/stable/handbook/text-anchors.html#quick-reference + anchor = 'ls' + + # Draw border around text + d.text((x - 1, y - 1), text, anchor=anchor, font=font, fill="black") + d.text((x + 1, y - 1), text, anchor=anchor, font=font, fill="black") + d.text((x - 1, y + 1), text, anchor=anchor, font=font, fill="black") + d.text((x + 1, y + 1), text, anchor=anchor, font=font, fill="black") + # Draw text itself + d.text((x, y), text, anchor=anchor, font=font, fill="white") + + return image + + +def save_image(image: Image.Image, dst: Path, + src: Path | exif.ImageFiles = None, + orientation: bool = True, + gps: bool = True, + date: bool = True + ) -> Path: + """ + Save image and copy some metadata from original image. + + :param image: + :param dst: + :param src: + :param orientation: + :param gps: + :param date: + :return: + """ + # Save image. + # + # JPEG quality: https://jdhao.github.io/2019/07/20/pil_jpeg_image_quality/#other-options-for-quality + # JPEG presets: https://github.com/python-pillow/Pillow/blob/main/src/PIL/JpegPresets.py#L67 + # e.g. web_low, web_medium, web_high, web_very_high, web_maximum + image.save(dst, format='jpeg', quality='web_low') + + if orientation or gps or date: + copy_metadata(src, dst, orientation, gps, date) + + return dst + + +def copy_metadata(src: Path | exif.ImageFiles, + dst: Path, + orientation: bool = True, + gps: bool = True, + date: bool = True) -> None: + if not (orientation or gps or date): + return + + if isinstance(src, exif.ImageFiles): + src = src.get_metadata_file() + + args = ['exiftool', '-overwrite_original', '-tagsfromfile', str(src)] + if orientation: + args += ['-orientation'] + if gps: + args += ['-gps:all'] + if date: + args += ['-alldates'] + args += [str(dst)] + + print(f' Copying metadata ...') + execute_save(args) diff --git a/main.py b/main.py index 249f18f..f795f14 100644 --- a/main.py +++ b/main.py @@ -1,55 +1,32 @@ import os from pathlib import Path -from typing import List, Union -import blur -import exec import exif +import image_manipulation from exif import ExifImageRegion, get_exif_image_regions # ======================================================================================================= # -# Adjust path to folder with images -image_directory = Path('example') +image_directory = Path('example') # Image directory. -# Enter name of persons to be blurred. Leave empty to blur any face. -names = ['A', 'B', 'C'] -# names = [] +names = ['A', 'B', 'C'] # Names of Persons to be blurred. +# names = [] # Blurr all faces. -delete_original: bool = True # deletes the original image after blurring image was created -copy_metadata_gps: bool = True # copies gps location from original to blurred image -copy_metadata_date: bool = True # copies date from original to blurred image +resolution = 2048 # Resize image. +# resolution = None # Keep original image size. +text = "Example" # Add text to image. +# text = None # Do not add text. -# Lower-case image extensions. +copy_metadata_orientation: bool = False # Copies orientation metadata from original image. +copy_metadata_gps: bool = True # Copies gps location metadata from original image. +copy_metadata_date: bool = True # Copies date metadata from original image. + +# List of lower-case image extensions. image_extensions = ['.jpg', '.jpeg', '.png'] -r_types = ['Face'] # ======================================================================================================= # - -def blur_image(image: exif.Image) -> Union[Path, None]: - """ - If at least one tagged area of the image matches the criteria, - a blurred image is created and its path is returned. - """ - exif_image_regions: List[ExifImageRegion] = get_exif_image_regions(image=image) - - # Blur only some faces. - normalized_rectangles = [] - for region in exif_image_regions: - if len(r_types) > 0 and region.r_type not in r_types: - continue - if region.name in names or len(names) == 0: - normalized_rectangles += [blur.NormalizedRectangle.of_exif_image_region(region)] - - if len(normalized_rectangles) > 0: - print(f' Blurring {len(normalized_rectangles)} areas ...') - return blur.blur_rectangle1(image_src=image, normalized_rectangles=normalized_rectangles) - else: - return None - - def main(): # Convert all images in `image_directory`, including subdirectories. for _, _, files in os.walk(image_directory): @@ -57,25 +34,53 @@ def main(): file: Path = Path.joinpath(image_directory, relative_file_str) if file.suffix.lower() not in image_extensions: continue - if file.stem.endswith(blur.stem_suffix()): + if stem_suffix() in file.stem: print(f'Skipped the following image as it is already blurred:\n\t{file}') continue - if blur.get_image_dst(file).exists(): - print(f'Skipped the following image as it\'s blurred output does already exist:\n\t{file}') - continue + dst = get_image_dst(file) + if dst.exists(): + # Blur again as the source image might have different face tags now. + dst.unlink() print(f'{file}') - blurred_img = blur_image(exif.Image(file)) + blur_image(exif.ImageFiles(file), dst) - if blurred_img is not None and copy_metadata_gps: - print(f' Copying gps metadata to blurred file ...') - exec.execute_save(['exiftool', '-tagsfromfile', str(file), '-gps:all', str(blurred_img)]) - if blurred_img is not None and copy_metadata_date: - print(f' Copying date metadata to blurred file ...') - exec.execute_save(['exiftool', '-tagsfromfile', str(file), '-alldates', str(blurred_img)]) - if blurred_img is not None and delete_original: - print(f' Deleting original file ...') - file.unlink() + +def blur_image(image_files: exif.ImageFiles, dst: Path) -> Path: + """ + If at least one tagged area of the image matches the criteria, + a blurred image is created and its path is returned. + """ + exif_image_regions: list[ExifImageRegion] = get_exif_image_regions(image=image_files) + + # Blur only some faces. + areas = [] + r_types = ['Face'] + for region in exif_image_regions: + if len(r_types) > 0 and region.r_type not in r_types: + continue + if region.name in names or len(names) == 0: + areas.append(region) + + print(f' Blurring {len(areas)} areas ...') + + im = image_manipulation.open_image(image_files) + im = image_manipulation.blur(im, areas) + im = image_manipulation.resize(im, resolution) + im = image_manipulation.add_text(im, text) + return image_manipulation.save_image(im, dst, image_files) + + +def get_image_dst(image: Path) -> Path: + return image.parent.joinpath(f'{image.stem}{stem_suffix()}{image.suffix}') + + +def stem_suffix() -> str: + """ + Modified images will be saved with a different filename. + This suffix will be added to their stem. + """ + return '[blurred]' if __name__ == '__main__': diff --git a/mask.png b/mask.png deleted file mode 100644 index 5bbfb73..0000000 Binary files a/mask.png and /dev/null differ diff --git a/exec.py b/subprocess_util.py similarity index 100% rename from exec.py rename to subprocess_util.py