""" XMP Manager Module for Bagheera. This module provides a dedicated class for handling XMP metadata, specifically for reading and writing face region information compliant with the Metadata Working Group (MWG) standard. It relies on the `exiv2` library for all metadata operations. Classes: XmpManager: A class with static methods to interact with XMP metadata. Dependencies: - python-exiv2: The Python binding for the exiv2 library. The module will gracefully handle its absence by disabling its functionality. - utils.preserve_mtime: A utility to prevent file modification times from changing during metadata writes. """ import os import re from utils import preserve_mtime from metadatamanager import notify_baloo try: import exiv2 except ImportError: exiv2 = None class XmpManager: """ A static class that provides methods to read and write face region data to and from XMP metadata in image files. """ @staticmethod def load_faces(path): """ Loads face regions from a file's XMP metadata (MWG Regions). This method parses the XMP data structure for a `mwg-rs:RegionList`, extracts all regions of type 'Face', and returns them as a list of dictionaries. Each dictionary contains the face's name and its normalized coordinates (center x, center y, width, height). Args: path (str): The path to the image file. Returns: list: A list of dictionaries, where each dictionary represents a face. Returns an empty list if exiv2 is not available or on error. """ if not exiv2 or not path or not os.path.exists(path): return [] faces = [] try: img = exiv2.ImageFactory.open(path) # readMetadata() is crucial to populate the data structures. img.readMetadata() xmp = img.xmpData() regions = {} for datum in xmp: key = datum.key() if "mwg-rs:RegionList" in key: # Use regex to find the index of the region in the list, # e.g., RegionList[1], RegionList[2], etc. m = re.search(r'RegionList\[(\d+)\]', key) if m: idx = int(m.group(1)) if idx not in regions: regions[idx] = {} val = datum.toString() if key.endswith("/mwg-rs:Name"): regions[idx]['name'] = val elif key.endswith("/stArea:x"): regions[idx]['x'] = float(val) elif key.endswith("/stArea:y"): regions[idx]['y'] = float(val) elif key.endswith("/stArea:w"): regions[idx]['w'] = float(val) elif key.endswith("/stArea:h"): regions[idx]['h'] = float(val) elif key.endswith("/mwg-rs:Type"): regions[idx]['type'] = val # Convert the structured dictionary into a flat list of faces, # preserving all regions (including 'Pet', etc.) to avoid data loss. for idx, data in sorted(regions.items()): if 'x' in data and 'y' in data and 'w' in data and 'h' in data: faces.append(data) except Exception as e: print(f"Error loading faces from XMP: {e}") return faces @staticmethod def save_faces(path, faces): """ Saves a list of faces to a file's XMP metadata as MWG Regions. This method performs a clean write by first removing all existing face region metadata from the file and then writing the new data. This method preserves the file's original modification time. Args: path (str): The path to the image file. faces (list): A list of face dictionaries to save. Returns: bool: True on success, False on failure. """ if not exiv2 or not path: return False try: # Register required XMP namespaces to ensure they are recognized. exiv2.XmpProperties.registerNs( "http://www.metadataworkinggroup.com/schemas/regions/", "mwg-rs") exiv2.XmpProperties.registerNs( "http://ns.adobe.com/xmp/sType/Area#", "stArea") with preserve_mtime(path): img = exiv2.ImageFactory.open(path) img.readMetadata() xmp = img.xmpData() # 1) Remove all existing RegionList entries to prevent conflicts. keys_to_delete = [ d.key() for d in xmp if d.key().startswith("Xmp.mwg-rs.Regions/mwg-rs:RegionList") ] for key in sorted(keys_to_delete, reverse=True): try: xmp_key = exiv2.XmpKey(key) it = xmp.findKey(xmp_key) if it != xmp.end(): xmp.erase(it) except Exception: pass # 2) Recreate the RegionList from the provided faces list. if faces: # To initialize an XMP list (rdf:Bag), it is necessary to # register the key as an array before it can be indexed. # Failing to do so causes the "XMP Toolkit error 102: # Indexing applied to non-array". A compatible way to do # this with the python-exiv2 binding is to assign an # XmpTextValue and specify its type as 'Bag', which # correctly creates the empty array structure. if exiv2 and hasattr(exiv2, 'XmpTextValue'): xmp["Xmp.mwg-rs.Regions/mwg-rs:RegionList"] = \ exiv2.XmpTextValue("type=Bag") for i, face in enumerate(faces): # The index for XMP arrays is 1-based. base = f"Xmp.mwg-rs.Regions/mwg-rs:RegionList[{i+1}]" xmp[f"{base}/mwg-rs:Name"] = face.get('name', 'Unknown') xmp[f"{base}/mwg-rs:Type"] = face.get('type', 'Face') area_base = f"{base}/mwg-rs:Area" xmp[f"{area_base}/stArea:x"] = str(face.get('x', 0)) xmp[f"{area_base}/stArea:y"] = str(face.get('y', 0)) xmp[f"{area_base}/stArea:w"] = str(face.get('w', 0)) xmp[f"{area_base}/stArea:h"] = str(face.get('h', 0)) xmp[f"{area_base}/stArea:unit"] = 'normalized' img.writeMetadata() notify_baloo(path) return True except Exception as e: print(f"Error saving faces to XMP: {e}") return False