mirror of
https://codeberg.org/privacy1st/blur-exif-face-tags
synced 2024-12-22 01:16:04 +01:00
init
This commit is contained in:
commit
4ba94173cb
21
LICENSE
Normal file
21
LICENSE
Normal 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
34
README.md
Normal 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
BIN
Screenshot-DigiKam.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 3.4 MiB |
BIN
__pycache__/blur.cpython-39.pyc
Normal file
BIN
__pycache__/blur.cpython-39.pyc
Normal file
Binary file not shown.
BIN
__pycache__/exec.cpython-39.pyc
Normal file
BIN
__pycache__/exec.cpython-39.pyc
Normal file
Binary file not shown.
BIN
__pycache__/exif.cpython-39.pyc
Normal file
BIN
__pycache__/exif.cpython-39.pyc
Normal file
Binary file not shown.
86
blur.py
Normal file
86
blur.py
Normal 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
BIN
example/unsplash.jpg
Normal file
Binary file not shown.
After Width: | Height: | Size: 2.2 MiB |
112
example/unsplash.jpg.xmp
Normal file
112
example/unsplash.jpg.xmp
Normal 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="<Categories><Category Assigned="0">People<Category Assigned="1">C</Category><Category Assigned="1">A</Category><Category Assigned="1">B</Category></Category></Categories>">
|
||||
<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"?>
|
BIN
example/unsplash_blurred.jpg
Normal file
BIN
example/unsplash_blurred.jpg
Normal file
Binary file not shown.
After Width: | Height: | Size: 2.0 MiB |
29
exec.py
Normal file
29
exec.py
Normal 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
99
exif.py
Normal 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
38
main.py
Normal 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()
|
Loading…
Reference in New Issue
Block a user