commit 4ba94173cbaa9e439da409382fff83966a8ea82e Author: Daniel Langbein Date: Sun Aug 29 14:17:01 2021 +0200 init diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..2ffa165 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2021 Daniel Langbein + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..e889923 --- /dev/null +++ b/README.md @@ -0,0 +1,34 @@ +# Blur persons and objects in your images based on exif metadata + +## Example Workflow + +1) Face-tag persons with their names on your pictures + * This can be done with e.g. `DigiKam` + +> Screenshot of DigiKam with three tagged persons (named "A", "B" and "C"): +> +> ![](Screenshot-DigiKam.png) + +2) Copy all pictures to be modified in a folder + +> See [example/](example/) + +3) Run this python script on that folder with a list of names of persons to be blurred + +> In `main.py` the three names are specified: +> ``` +> names = ['A', 'B', 'C'] # TODO: Enter name of persons to be blurred +> ``` +> +> This results in the following picture: +> +> ![](example/unsplash_blurred.jpg) + + +## Credits + +Many thanks to + +* https://www.thregr.org/~wavexx/software/facedetect/#blurring-faces-within-an-image +* "Make blur all around a rectangle in image with PIL", https://stackoverflow.com/q/56987112/6334421 +* Example image: https://unsplash.com/photos/1qfy-jDc_jo?utm_source=unsplash&utm_medium=referral&utm_content=creditShareLink \ No newline at end of file diff --git a/Screenshot-DigiKam.png b/Screenshot-DigiKam.png new file mode 100644 index 0000000..9e7bf64 Binary files /dev/null and b/Screenshot-DigiKam.png differ diff --git a/__pycache__/blur.cpython-39.pyc b/__pycache__/blur.cpython-39.pyc new file mode 100644 index 0000000..274e244 Binary files /dev/null and b/__pycache__/blur.cpython-39.pyc differ diff --git a/__pycache__/exec.cpython-39.pyc b/__pycache__/exec.cpython-39.pyc new file mode 100644 index 0000000..712ee02 Binary files /dev/null and b/__pycache__/exec.cpython-39.pyc differ diff --git a/__pycache__/exif.cpython-39.pyc b/__pycache__/exif.cpython-39.pyc new file mode 100644 index 0000000..fa26404 Binary files /dev/null and b/__pycache__/exif.cpython-39.pyc differ diff --git a/blur.py b/blur.py new file mode 100644 index 0000000..07e0efc --- /dev/null +++ b/blur.py @@ -0,0 +1,86 @@ +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): + if region.area_unit == 'normalized': + blur_rectangle1(image_src, normalized_rectangles=[NormalizedRectangle.of_exif_image_region(region=region)], + image_dst=image_dst) + else: + raise Exception + + +def blur_rectangle1(image_src: exif.Image, normalized_rectangles: List[NormalizedRectangle], image_dst: Path = None): + 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): + if len(normalized_rectangles) == 0: + print('No rectangles to blur') + return + + # 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 = image_src.parent.joinpath(f'{image_src.stem}_blurred{image_src.suffix}') + im.save(image_dst) diff --git a/example/unsplash.jpg b/example/unsplash.jpg new file mode 100644 index 0000000..fffa47c Binary files /dev/null and b/example/unsplash.jpg differ diff --git a/example/unsplash.jpg.xmp b/example/unsplash.jpg.xmp new file mode 100644 index 0000000..8d1538a --- /dev/null +++ b/example/unsplash.jpg.xmp @@ -0,0 +1,112 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + People/B + People/A + People/C + + + + + People/B + People/A + People/C + + + + + People|B + People|A + People|C + + + + + People|B + People|A + People|C + + + + + B + A + C + + + + + + \ No newline at end of file diff --git a/example/unsplash_blurred.jpg b/example/unsplash_blurred.jpg new file mode 100644 index 0000000..6c51f6b Binary files /dev/null and b/example/unsplash_blurred.jpg differ diff --git a/exec.py b/exec.py new file mode 100644 index 0000000..497acf9 --- /dev/null +++ b/exec.py @@ -0,0 +1,29 @@ +from typing import List +import sys +import subprocess + +def execute_save(command: List[str]): + returncode, stdout, stderr = execute(command) + if returncode == 0: + return stdout + else: + raise Exception + + +def execute(command: List[str]): + """ + Run the given command in a subprocess and pass stdin (if given) to that process. + Wait for command to complete. + + :param command: A command to executed as list of words, e.g. ['echo', 'Hello world!'] + :return: (exit_code, stdout, stderr) + """ + + completed: subprocess.CompletedProcess = subprocess.run( + command, + stdin=sys.stdin, + capture_output=True, + check=False, + text=True + ) + return completed.returncode, completed.stdout, completed.stderr diff --git a/exif.py b/exif.py new file mode 100644 index 0000000..0c7287f --- /dev/null +++ b/exif.py @@ -0,0 +1,99 @@ +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 + + def get_image_file(self): + return self.files[0] + + def get_xmp_metadata(self) -> AnyStr: + # TODO: Try to read xmp metadata from the image file itself, if there is no sidecar xmp file + + xmp_sidecar = self.get_xmp_sidecar() + with open(xmp_sidecar, "r") as f: + return f.read() + + def get_xmp_sidecar(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 __str__(self): + return f'Image: {self.__dict__}' + + +class ExifImageRegion: + """ + A rectangular region of an image. + For example a face tag. + """ + + image: Image + + name: str # 'John' + r_type: str # 'Face' + area_unit: str # 'normalized' + rectangle: List[float] # [ x-coordinate, y-coordinate, width, height ] + + def __init__(self, image: Image, name, r_type, area_unit, rectangle): + self.image = image + self.name = name + self.r_type = r_type + self.area_unit = area_unit + self.rectangle = rectangle + + def __str__(self): + return f'ExifImageRegion: {self.__dict__}' + + +def get_exif_image_regions(image: Image) -> List[ExifImageRegion]: + sidecar: Path = image.get_xmp_sidecar() + + names_str = exec.execute_save(['exiftool', '-RegionName', str(sidecar)]) + r_types_str = exec.execute_save(['exiftool', '-RegionType', str(sidecar)]) + area_units_str = exec.execute_save(['exiftool', '-RegionAreaUnit', str(sidecar)]) + rectangles_str = exec.execute_save(['exiftool', '-RegionRectangle', str(sidecar)]) + + names = names_str.strip().split(':', 1)[1].strip().split(', ') + r_types = r_types_str.strip().split(':', 1)[1].strip().split(', ') + area_units = area_units_str.strip().split(':', 1)[1].strip().split(', ') + rectangles_tmp = rectangles_str.strip().split(':', 1)[1].strip().split(', ') + + assert len(rectangles_tmp) % 4 == 0 + rectangles = [] + for i in range(len(rectangles_tmp) // 4): + rectangles += [[]] + for j in range(4): + 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 + name, r_type, area_unit, rectangle in zip(names, r_types, area_units, rectangles)] diff --git a/main.py b/main.py new file mode 100644 index 0000000..5873469 --- /dev/null +++ b/main.py @@ -0,0 +1,38 @@ +from pathlib import Path +from typing import List + +import exif, blur + +from exif import ExifImageRegion, get_exif_image_regions + +image_extensions = ['.jpg', '.jpeg', '.png'] +image_directory = Path('example/') # TODO: Adjust path to folder with images + +r_type = 'Face' +names = ['A', 'B', 'C'] # TODO: Enter name of persons to be blurred + + +def blur_image(image: exif.Image): + exif_image_regions: List[ExifImageRegion] = get_exif_image_regions(image=image) + + # Blur all tagged areas + # normalized_rectangles = [blur.NormalizedRectangle.of_exif_image_region(region) for region in exif_image_regions] + + # Blur only some faces + normalized_rectangles = [] + for region in exif_image_regions: + if region.r_type == r_type and region.name in names: + normalized_rectangles += [blur.NormalizedRectangle.of_exif_image_region(region)] + print(f'{image} contains {len(normalized_rectangles)} tagged faces to be blurred!') + + blur.blur_rectangle1(image_src=image, normalized_rectangles=normalized_rectangles) + + +def main(): + for child in image_directory.iterdir(): + if child.suffix.lower() in image_extensions: + blur_image(exif.Image(child)) + + +if __name__ == '__main__': + main() diff --git a/mask.png b/mask.png new file mode 100644 index 0000000..5bbfb73 Binary files /dev/null and b/mask.png differ