This commit is contained in:
Daniel Langbein 2021-08-29 14:17:01 +02:00
commit 4ba94173cb
14 changed files with 419 additions and 0 deletions

21
LICENSE Normal file
View File

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2021 Daniel Langbein <daniel@systemli.org>
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.

34
README.md Normal file
View File

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

BIN
Screenshot-DigiKam.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 MiB

Binary file not shown.

Binary file not shown.

Binary file not shown.

86
blur.py Normal file
View File

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

BIN
example/unsplash.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

112
example/unsplash.jpg.xmp Normal file
View File

@ -0,0 +1,112 @@
<?xpacket begin="" id="W5M0MpCehiHzreSzNTczkc9d"?>
<x:xmpmeta xmlns:x="adobe:ns:meta/" x:xmptk="XMP Core 4.4.0-Exiv2">
<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
<rdf:Description rdf:about=""
xmlns:mwg-rs="http://www.metadataworkinggroup.com/schemas/regions/"
xmlns:stArea="http://ns.adobe.com/xmp/sType/Area#"
xmlns:MP="http://ns.microsoft.com/photo/1.2/"
xmlns:MPRI="http://ns.microsoft.com/photo/1.2/t/RegionInfo#"
xmlns:MPReg="http://ns.microsoft.com/photo/1.2/t/Region#"
xmlns:digiKam="http://www.digikam.org/ns/1.0/"
xmlns:MicrosoftPhoto="http://ns.microsoft.com/photo/1.0/"
xmlns:lr="http://ns.adobe.com/lightroom/1.0/"
xmlns:mediapro="http://ns.iview-multimedia.com/mediapro/1.0/"
xmlns:acdsee="http://ns.acdsee.com/iptc/1.0/"
xmlns:dc="http://purl.org/dc/elements/1.1/"
acdsee:categories="&lt;Categories&gt;&lt;Category Assigned=&quot;0&quot;&gt;People&lt;Category Assigned=&quot;1&quot;&gt;C&lt;/Category&gt;&lt;Category Assigned=&quot;1&quot;&gt;A&lt;/Category&gt;&lt;Category Assigned=&quot;1&quot;&gt;B&lt;/Category&gt;&lt;/Category&gt;&lt;/Categories&gt;">
<mwg-rs:Regions rdf:parseType="Resource">
<mwg-rs:RegionList>
<rdf:Bag>
<rdf:li>
<rdf:Description
mwg-rs:Name="A"
mwg-rs:Type="Face">
<mwg-rs:Area
stArea:x="0.20843"
stArea:y="0.317448"
stArea:w="0.0453318"
stArea:h="0.0892205"
stArea:unit="normalized"/>
</rdf:Description>
</rdf:li>
<rdf:li>
<rdf:Description
mwg-rs:Name="B"
mwg-rs:Type="Face">
<mwg-rs:Area
stArea:x="0.595679"
stArea:y="0.230359"
stArea:w="0.0343364"
stArea:h="0.0569428"
stArea:unit="normalized"/>
</rdf:Description>
</rdf:li>
<rdf:li>
<rdf:Description
mwg-rs:Name="C"
mwg-rs:Type="Face">
<mwg-rs:Area
stArea:x="0.784047"
stArea:y="0.295676"
stArea:w="0.0407022"
stArea:h="0.0937881"
stArea:unit="normalized"/>
</rdf:Description>
</rdf:li>
</rdf:Bag>
</mwg-rs:RegionList>
</mwg-rs:Regions>
<MP:RegionInfo rdf:parseType="Resource">
<MPRI:Regions>
<rdf:Bag>
<rdf:li
MPReg:PersonDisplayName="A"
MPReg:Rectangle="0.185764, 0.272838, 0.0453318, 0.0892205"/>
<rdf:li
MPReg:PersonDisplayName="B"
MPReg:Rectangle="0.578511, 0.201888, 0.0343364, 0.0569428"/>
<rdf:li
MPReg:PersonDisplayName="C"
MPReg:Rectangle="0.763696, 0.248782, 0.0407022, 0.0937881"/>
</rdf:Bag>
</MPRI:Regions>
</MP:RegionInfo>
<digiKam:TagsList>
<rdf:Seq>
<rdf:li>People/B</rdf:li>
<rdf:li>People/A</rdf:li>
<rdf:li>People/C</rdf:li>
</rdf:Seq>
</digiKam:TagsList>
<MicrosoftPhoto:LastKeywordXMP>
<rdf:Bag>
<rdf:li>People/B</rdf:li>
<rdf:li>People/A</rdf:li>
<rdf:li>People/C</rdf:li>
</rdf:Bag>
</MicrosoftPhoto:LastKeywordXMP>
<lr:hierarchicalSubject>
<rdf:Bag>
<rdf:li>People|B</rdf:li>
<rdf:li>People|A</rdf:li>
<rdf:li>People|C</rdf:li>
</rdf:Bag>
</lr:hierarchicalSubject>
<mediapro:CatalogSets>
<rdf:Bag>
<rdf:li>People|B</rdf:li>
<rdf:li>People|A</rdf:li>
<rdf:li>People|C</rdf:li>
</rdf:Bag>
</mediapro:CatalogSets>
<dc:subject>
<rdf:Bag>
<rdf:li>B</rdf:li>
<rdf:li>A</rdf:li>
<rdf:li>C</rdf:li>
</rdf:Bag>
</dc:subject>
</rdf:Description>
</rdf:RDF>
</x:xmpmeta>
<?xpacket end="w"?>

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

29
exec.py Normal file
View File

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

99
exif.py Normal file
View File

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

38
main.py Normal file
View File

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

BIN
mask.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB