From ceb5ef6d55709e65eeb678a196e1e69caf8842a8 Mon Sep 17 00:00:00 2001 From: HERITAGE-XION Date: Sun, 4 Jan 2026 17:29:50 +0100 Subject: [PATCH] metadata-scrubber-tool: added functionality for wiping exif data from jpeg images --- .gitignore | 2 + src/commands/utils.py | 1 - src/services/image_handler.py | 66 ++++++++++++++++++++++++++++++++ src/services/metadata_handler.py | 24 ++++++++++++ src/utils/__init__.py | 0 src/utils/jpeg_metadata.py | 54 ++++++++++++++++++++++++++ 6 files changed, 146 insertions(+), 1 deletion(-) create mode 100644 .gitignore delete mode 100644 src/commands/utils.py create mode 100644 src/services/image_handler.py create mode 100644 src/services/metadata_handler.py create mode 100644 src/utils/__init__.py create mode 100644 src/utils/jpeg_metadata.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..0e5ac793 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +.venv +__pycache__ \ No newline at end of file diff --git a/src/commands/utils.py b/src/commands/utils.py deleted file mode 100644 index d41ddc6d..00000000 --- a/src/commands/utils.py +++ /dev/null @@ -1 +0,0 @@ -# Utility functions for commands diff --git a/src/services/image_handler.py b/src/services/image_handler.py new file mode 100644 index 00000000..7d588d4c --- /dev/null +++ b/src/services/image_handler.py @@ -0,0 +1,66 @@ +import shutil +from pathlib import Path +from typing import Optional + +import piexif +from PIL import Image + +from src.services.metadata_handler import MetadataHandler +from src.utils.jpeg_metadata import JpegProcessaor + + +class ImageHandler(MetadataHandler): + def __init__(self, filepath: str): + super().__init__(filepath) + self.processors = {".jpeg": JpegProcessaor(), ".jpg": JpegProcessaor()} + self.tags_to_delete = [] + + def read(self): + """Extracts metadata into a standard dictionary.""" + with Image.open(Path(self.filepath)) as img: + extension = Path(self.filepath).suffix + processor = self.processors.get(extension) + + if not processor: + raise ValueError(f"Unsupported format: {extension}") + + self.metadata = processor.get_metadata(img)["data"] + self.tags_to_delete = processor.get_metadata(img)["tags_to_delete"] + return self.metadata + + def wipe(self) -> None: + """Wipes internal metadata state.""" + with Image.open(Path(self.filepath)) as img: + extension = Path(self.filepath).suffix + processor = self.processors.get(extension) + + if not processor: + raise ValueError(f"Unsupported format: {extension}") + + self.processed_metadata = processor.delete_metadata( + img, self.tags_to_delete + ) + + def save(self, output_path: Optional[str] = None) -> None: + """Writes the changes to a copy of the original file.""" + destination_dir = Path(output_path or "/archive/") + + # creates the destination directory if it doesn't exist + destination_dir.mkdir(parents=True, exist_ok=True) + destination_file_path = ( + destination_dir / f"processed_{Path(self.filepath).name}" + ) + + # copies the original file to the destination directory + shutil.copy2(self.filepath, destination_file_path) + + # writes the processed metadata to the image in the destination directory + with Image.open(destination_file_path) as img: + exif_bytes = piexif.dump(self.processed_metadata) + img.save(destination_file_path, exif=exif_bytes) + + +test = ImageHandler(r"C:\Users\Xheri\development\testimage\20221201_090615.jpg") +test.read() +test.wipe() +test.save() diff --git a/src/services/metadata_handler.py b/src/services/metadata_handler.py new file mode 100644 index 00000000..89c2e2db --- /dev/null +++ b/src/services/metadata_handler.py @@ -0,0 +1,24 @@ +from abc import ABC, abstractmethod +from typing import Any, Dict, Optional + + +class MetadataHandler(ABC): + def __init__(self, filepath: str): + self.filepath = filepath + self.metadata: Dict[str, Any] = {} + self.processed_metadata: Dict[str, Any] = {} + + @abstractmethod + def read(self) -> Dict[str, Any]: + """Extracts metadata into a standard dictionary.""" + pass + + @abstractmethod + def wipe(self) -> None: + """Updates internal metadata state.""" + pass + + @abstractmethod + def save(self, output_path: Optional[str] = None) -> None: + """Writes the changes to a file.""" + pass diff --git a/src/utils/__init__.py b/src/utils/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/utils/jpeg_metadata.py b/src/utils/jpeg_metadata.py new file mode 100644 index 00000000..8428c1f4 --- /dev/null +++ b/src/utils/jpeg_metadata.py @@ -0,0 +1,54 @@ +import piexif + + +class JpegProcessaor: + def __init__(self): + self.tags_to_delete = [] + self.data = {} + + def get_metadata(self, img): + if "exif" in img.info: + exif_dict = piexif.load(img.info["exif"]) + for ifd, value in exif_dict.items(): + # this exclude thumbnail IFD (its the thumbnails blob data. it can be removed but the image will take a couple more seconds to load) + if not isinstance(exif_dict[ifd], dict): + continue + + # iterate through the IFD + for tag, tag_value in exif_dict[ifd].items(): + tag_info = piexif.TAGS[ifd].get(tag, {}) + + # Get the human-readable name for the tag + tag_name = ( + tag_info.get("name", "Unknown Tag") + if tag_info + else "Unknown Tag" + ) + + # exculudes tags that are necessary for image display integrity + if ( + tag_name == "Orientation" + or tag_name == "ColorSpace" + or tag_name == "ExifTag" + ): + continue + + # save to list and dict + self.tags_to_delete.append(tag) + self.data[tag_name] = tag_value + + return {"data": self.data, "tags_to_delete": self.tags_to_delete} + + def delete_metadata(self, img, tags_to_delete): + exif_dict = piexif.load(img.info["exif"]) + for ifd, value in exif_dict.items(): + # exclude thumbnail IFD (its the thumbnails blob data so i dont wanna deal with that) + if not isinstance(exif_dict[ifd], dict): + continue + + # iterate through and delete tags + for tag in list(exif_dict[ifd]): + if tag in tags_to_delete: + del exif_dict[ifd][tag] + + return exif_dict