multiple improvements

- resize image
- copy (more) image metadata
- orientate image
- add text to image
This commit is contained in:
Daniel Langbein 2022-08-28 16:23:36 +02:00
parent d577396768
commit 01abab3424
11 changed files with 327 additions and 218 deletions

View File

@ -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

32
area.py Normal file
View File

@ -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()

103
blur.py
View File

@ -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'

Binary file not shown.

After

Width:  |  Height:  |  Size: 237 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 MiB

75
exif.py
View File

@ -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)]

51
image_files.py Normal file
View File

@ -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__}'

175
image_manipulation.py Normal file
View File

@ -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)

105
main.py
View File

@ -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__':

BIN
mask.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB