Files

2260 lines
93 KiB
Python
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
embedded.py - Embedded Metadata Handler
Description: This module provides functions for reading and writing embedded metadata in image files using PyExiv2 and ExifTool.
Author: Eric Hiss (GitHub: EricRollei)
Contact: [eric@historic.camera, eric@rollei.us]
Version: 1.0.0
Date: [March 2025]
License: Dual License (Non-Commercial and Commercial Use)
Copyright (c) 2025 Eric Hiss. All rights reserved.
Dual License:
1. Non-Commercial Use: This software is licensed under the terms of the
Creative Commons Attribution-NonCommercial 4.0 International License.
To view a copy of this license, visit http://creativecommons.org/licenses/by-nc/4.0/
2. Commercial Use: For commercial use, a separate license is required.
Please contact Eric Hiss at [eric@historic.camera, eric@rollei.us] for licensing options.
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.
Dependencies:
This code depends on several third-party libraries, each with its own license:
"""
# Metadata_system/src/eric_metadata/handlers/embedded.py
import os
import json
from typing import Dict, Any, Optional, List, Tuple, Union
import re
import datetime
import uuid
import subprocess
import numpy as np
import xml.etree.ElementTree as ET
from ..handlers.base import BaseHandler
from ..utils.format_detect import FormatHandler
from ..utils.namespace import NamespaceManager
from ..utils.error_handling import ErrorRecovery
class EmbeddedMetadataHandler(BaseHandler):
"""Handler for embedded metadata in image files"""
def __init__(self, debug: bool = False):
"""
Initialize the embedded metadata handler
Args:
debug: Whether to enable debug logging
"""
super().__init__(debug)
# Initialize PyExiv2
self._initialize_pyexiv2()
# Register namespaces
self._register_namespaces()
# Create ExifTool config
self._exiftool_config = self._create_exiftool_config()
# Track current metadata object for cleanup
self._current_metadata = None
# Resource identifier for XMP
self.resource_about = ""
def write_metadata(self, filepath: str, metadata: Dict[str, Any]) -> bool:
"""
Write metadata to image file
Args:
filepath: Path to the image file
metadata: Metadata to write
Returns:
bool: True if successful
"""
try:
# Get format information
format_info = FormatHandler.get_file_info(filepath)
# Choose appropriate write method based on format
if format_info['can_use_pyexiv2']:
return self._write_with_pyexiv2(filepath, metadata)
elif format_info['requires_exiftool']:
return self._write_with_exiftool(filepath, metadata)
elif format_info['is_standard']:
# Some formats like PNG need special handling
return self._write_standard_format(filepath, metadata, format_info)
else:
self.log(f"Unsupported format for embedded metadata: {format_info['extension']}", level="WARNING")
return False
except Exception as e:
self.log(f"Error writing embedded metadata: {str(e)}", level="ERROR", error=e)
# Attempt recovery
context = {
'filepath': filepath,
'metadata': metadata,
'error_type': type(e).__name__,
'error': str(e)
}
return ErrorRecovery.recover_write_error(self, context)
def read_metadata(self, filepath: str) -> Dict[str, Any]:
"""
Read metadata from image file
Args:
filepath: Path to the image file
Returns:
dict: Metadata from image file
"""
try:
# Get format information
format_info = FormatHandler.get_file_info(filepath)
# Choose appropriate read method based on format
if format_info['can_use_pyexiv2']:
return self._read_with_pyexiv2(filepath)
elif format_info['requires_exiftool']:
return self._read_with_exiftool(filepath)
elif format_info['is_standard']:
# Some formats like PNG need special handling
return self._read_standard_format(filepath, format_info)
else:
self.log(f"Unsupported format for embedded metadata: {format_info['extension']}", level="WARNING")
return {}
except Exception as e:
self.log(f"Error reading embedded metadata: {str(e)}", level="ERROR", error=e)
# Attempt recovery
context = {
'filepath': filepath,
'error_type': type(e).__name__,
'error': str(e)
}
return ErrorRecovery.recover_read_error(self, context)
def _initialize_pyexiv2(self):
"""Initialize PyExiv2 module"""
try:
import pyexiv2
if hasattr(pyexiv2, 'exiv2api') and hasattr(pyexiv2.exiv2api, 'init'):
pyexiv2.exiv2api.init()
self.log("PyExiv2 initialized successfully")
except ImportError:
self.log("PyExiv2 not available", level="WARNING")
except Exception as e:
self.log(f"Failed to initialize PyExiv2: {str(e)}", level="WARNING")
# In embedded.py, modify the _register_namespaces method
def _register_namespaces(self):
"""Register namespaces with PyExiv2"""
success = NamespaceManager.register_with_pyexiv2(self.debug)
self.log(f"Namespace registration {'succeeded' if success else 'failed'}", level="DEBUG")
# Verify specific namespaces (add this for debugging)
if self.debug:
try:
import pyexiv2
if hasattr(pyexiv2, 'exiv2api') and hasattr(pyexiv2.exiv2api, 'registeredXmpNamespaces'):
namespaces = pyexiv2.exiv2api.registeredXmpNamespaces()
self.log(f"Registered XMP namespaces: {namespaces}", level="DEBUG")
except Exception as e:
self.log(f"Could not verify namespaces: {e}", level="DEBUG")
def _create_exiftool_config(self) -> str:
"""Create ExifTool configuration file"""
return NamespaceManager.create_exiftool_config()
def _write_with_pyexiv2(self, filepath: str, metadata: Dict[str, Any]) -> bool:
"""
Write metadata using PyExiv2
Args:
filepath: Path to the image file
metadata: Metadata to write
Returns:
bool: True if successful
"""
try:
import pyexiv2
# Open image
img = pyexiv2.Image(filepath)
self._current_metadata = img
# Read existing metadata for merging
existing_xmp = img.read_xmp() or {}
existing_iptc = img.read_iptc() or {}
existing_exif = img.read_exif() or {}
# Prepare metadata for writing
xmp_data = {}
iptc_data = {}
exif_data = {}
# Process basic metadata
if 'basic' in metadata:
basic_metadata = self._prepare_basic_metadata(metadata['basic'], existing_iptc)
xmp_data.update(basic_metadata['xmp'])
iptc_data.update(basic_metadata['iptc'])
exif_data.update(basic_metadata['exif'])
# Process analysis data
if 'analysis' in metadata:
analysis_xmp = self._prepare_analysis_metadata(metadata['analysis'], existing_xmp)
xmp_data.update(analysis_xmp)
# Process AI info - most important for generation data
if 'ai_info' in metadata:
ai_xmp = self._prepare_ai_metadata(metadata['ai_info'], existing_xmp)
xmp_data.update(ai_xmp)
# Process regions data
if 'regions' in metadata:
region_xmp = self._prepare_region_metadata(metadata['regions'], existing_xmp)
xmp_data.update(region_xmp)
# Write metadata
self.log(f"Writing {len(xmp_data)} XMP fields to {filepath}", level="DEBUG")
if xmp_data:
img.modify_xmp(xmp_data)
if iptc_data:
img.modify_iptc(iptc_data)
if exif_data:
img.modify_exif(exif_data)
# Close and save
img.close()
self._current_metadata = None
return True
except Exception as e:
self.log(f"PyExiv2 write failed: {str(e)}", level="ERROR", error=e)
self._cleanup_current_metadata()
return False
def _read_with_pyexiv2(self, filepath: str) -> Dict[str, Any]:
"""
Read metadata using PyExiv2
Args:
filepath: Path to the image file
Returns:
dict: Metadata from image file
"""
try:
import pyexiv2
# Open image
img = pyexiv2.Image(filepath)
self._current_metadata = img
result = {
'basic': {},
'analysis': {},
'ai_info': {}
}
# Read XMP metadata
xmp_data = img.read_xmp() or {}
# Read IPTC metadata
iptc_data = img.read_iptc() or {}
# Read EXIF metadata
exif_data = img.read_exif() or {}
# Extract basic metadata
self._extract_basic_metadata(result, xmp_data, iptc_data, exif_data)
# Extract analysis metadata
self._extract_analysis_metadata(result, xmp_data)
# Extract AI metadata
self._extract_ai_metadata(result, xmp_data)
# Extract region data
self._extract_region_metadata(result, xmp_data)
# Close image
img.close()
self._current_metadata = None
return result
except Exception as e:
self.log(f"PyExiv2 read failed: {str(e)}", level="ERROR", error=e)
self._cleanup_current_metadata()
return {}
def _write_with_exiftool(self, filepath: str, metadata: Dict[str, Any]) -> bool:
"""
Write metadata using ExifTool
Args:
filepath: Path to the image file
metadata: Metadata to write
Returns:
bool: True if successful
"""
try:
# Create a temporary JSON file with the metadata
temp_json = f"{filepath}.temp.json"
# Convert metadata to ExifTool format
exiftool_meta = self._convert_to_exiftool_format(metadata)
with open(temp_json, 'w', encoding='utf-8') as f:
json.dump(exiftool_meta, f, indent=2)
# Build ExifTool command
cmd = [
'exiftool',
'-config', self._exiftool_config,
'-json', temp_json,
'-overwrite_original',
filepath
]
# Run ExifTool
result = subprocess.run(cmd, capture_output=True, text=True)
# Clean up temp file
if os.path.exists(temp_json):
os.remove(temp_json)
if result.returncode != 0:
self.log(f"ExifTool error: {result.stderr}", level="ERROR")
return False
return True
except Exception as e:
self.log(f"ExifTool write failed: {str(e)}", level="ERROR", error=e)
return False
def _read_with_exiftool(self, filepath: str) -> Dict[str, Any]:
"""
Read metadata using ExifTool
Args:
filepath: Path to the image file
Returns:
dict: Metadata from image file
"""
try:
# Build ExifTool command
cmd = [
'exiftool',
'-config', self._exiftool_config,
'-json',
'-a', # All tags
'-G', # Group names
filepath
]
# Run ExifTool
result = subprocess.run(cmd, capture_output=True, text=True)
if result.returncode != 0:
self.log(f"ExifTool error: {result.stderr}", level="ERROR")
return {}
# Parse JSON output
try:
exiftool_data = json.loads(result.stdout)
if not exiftool_data:
return {}
# Convert to our format
return self._convert_from_exiftool_format(exiftool_data[0])
except json.JSONDecodeError as e:
self.log(f"Failed to parse ExifTool output: {str(e)}", level="ERROR")
return {}
except Exception as e:
self.log(f"ExifTool read failed: {str(e)}", level="ERROR", error=e)
return {}
# Metadata_system/src/eric_metadata/handlers/embedded.py (continued)
def _write_standard_format(self, filepath: str, metadata: Dict[str, Any],
format_info: Dict[str, Any]) -> bool:
"""
Write metadata to standard formats with special handling
Args:
filepath: Path to the image file
metadata: Metadata to write
format_info: Format information
Returns:
bool: True if successful
"""
# Handle PNG format
if format_info['extension'] in ['.png']:
return self._write_png_metadata(filepath, metadata)
# Handle WebP format
elif format_info['extension'] in ['.webp']:
return self._write_webp_metadata(filepath, metadata)
# Other formats - try ExifTool as fallback
return self._write_with_exiftool(filepath, metadata)
def _read_standard_format(self, filepath: str, format_info: Dict[str, Any]) -> Dict[str, Any]:
"""
Read metadata from standard formats with special handling
Args:
filepath: Path to the image file
format_info: Format information
Returns:
dict: Metadata from image file
"""
# Handle PNG format
if format_info['extension'] in ['.png']:
return self._read_png_metadata(filepath)
# Handle WebP format
elif format_info['extension'] in ['.webp']:
return self._read_webp_metadata(filepath)
# Other formats - try ExifTool as fallback
return self._read_with_exiftool(filepath)
def _write_png_metadata(self, filepath: str, metadata: Dict[str, Any]) -> bool:
"""
Write metadata to PNG file while preserving workflow data
Args:
filepath: Path to the PNG file
metadata: Metadata to write
Returns:
bool: True if successful
"""
try:
from PIL import Image
from PIL.PngImagePlugin import PngInfo
# First extract existing metadata including workflow
existing_info = {}
workflow_data = None
with Image.open(filepath) as img:
# Store all existing metadata
for key, value in img.info.items():
existing_info[key] = value
# Check for workflow data
if key == 'parameters':
workflow_data = value
self.log(f"Found workflow data in PNG", level="DEBUG")
# Create new metadata structure
info = PngInfo()
# Add all existing metadata back except 'parameters' (workflow) which we'll handle separately
for key, value in existing_info.items():
if key != 'parameters' and key != 'XML:com.adobe.xmp':
info.add_text(key, str(value))
# Prepare XMP metadata as a string
xmp_data = self._prepare_xmp_packet(metadata)
if xmp_data:
info.add_text('XML:com.adobe.xmp', xmp_data)
# Add back workflow data if it existed
if workflow_data is not None:
info.add_text('parameters', workflow_data)
# Open and modify the image
with Image.open(filepath) as img:
# Create a temporary file
temp_filepath = f"{filepath}.temp.png"
# Save with updated metadata
img.save(temp_filepath, "PNG", pnginfo=info)
# Replace original with temp file
import os
os.replace(temp_filepath, filepath)
return True
except Exception as e:
self.log(f"PNG metadata write failed: {str(e)}", level="ERROR", error=e)
return False
def _read_png_metadata(self, filepath: str) -> Dict[str, Any]:
"""
Read metadata from PNG file including workflow data
Args:
filepath: Path to the PNG file
Returns:
dict: Metadata from PNG file
"""
try:
from PIL import Image
result = {
'basic': {},
'analysis': {},
'ai_info': {}
}
with Image.open(filepath) as img:
# Get XMP metadata
if 'XML:com.adobe.xmp' in img.info:
xmp_data = img.info['XML:com.adobe.xmp']
# Parse XMP data
xmp_metadata = self._parse_xmp_packet(xmp_data)
if xmp_metadata:
self._merge_xmp_metadata(result, xmp_metadata)
# Get workflow data
if 'parameters' in img.info:
workflow_data = img.info['parameters']
workflow = self._parse_workflow_data(workflow_data)
if workflow:
# Store in ai_info section
if 'ai_info' not in result:
result['ai_info'] = {}
result['ai_info']['workflow'] = workflow
return result
except Exception as e:
self.log(f"PNG metadata read failed: {str(e)}", level="ERROR", error=e)
return {}
def _write_webp_metadata(self, filepath: str, metadata: Dict[str, Any]) -> bool:
"""
Write metadata to WebP file
Args:
filepath: Path to the WebP file
metadata: Metadata to write
Returns:
bool: True if successful
"""
# WebP has limited metadata support, so we use ExifTool
return self._write_with_exiftool(filepath, metadata)
def _read_webp_metadata(self, filepath: str) -> Dict[str, Any]:
"""
Read metadata from WebP file
Args:
filepath: Path to the WebP file
Returns:
dict: Metadata from WebP file
"""
# WebP has limited metadata support, so we use ExifTool
return self._read_with_exiftool(filepath)
def _parse_workflow_data(self, workflow_data: str) -> Dict[str, Any]:
"""
Parse ComfyUI workflow data
Args:
workflow_data: Workflow data as string
Returns:
dict: Parsed workflow data
"""
try:
# Try to parse as JSON
import json
# Handle string or dict input
if isinstance(workflow_data, str):
workflow = json.loads(workflow_data)
else:
workflow = workflow_data
# Check for the different sections
result = {}
# Handle "prompt" section (older format)
if 'prompt' in workflow and isinstance(workflow['prompt'], dict):
result['prompt'] = workflow['prompt']
# Handle "workflow" section (newer format)
if 'workflow' in workflow and isinstance(workflow['workflow'], dict):
result['workflow'] = workflow['workflow']
# Extract generation parameters from nodes if present
if 'prompt' in workflow and 'nodes' in workflow['prompt']:
result['parameters'] = self._extract_parameters_from_nodes(workflow['prompt']['nodes'])
return result
except Exception as e:
self.log(f"Workflow parsing failed: {str(e)}", level="WARNING")
# Return the raw data if parsing fails
return {'raw': str(workflow_data)}
def _extract_parameters_from_nodes(self, nodes: Dict[str, Any]) -> Dict[str, Any]:
"""
Extract generation parameters from workflow nodes
Args:
nodes: Workflow nodes
Returns:
dict: Extracted parameters
"""
parameters = {}
# Map of node types to parameter names
node_parameter_map = {
'KSampler': {
'seed': ('inputs', 'seed'),
'steps': ('inputs', 'steps'),
'cfg': ('inputs', 'cfg'),
'sampler_name': ('inputs', 'sampler_name'),
'scheduler': ('inputs', 'scheduler'),
'denoise': ('inputs', 'denoise')
},
'CLIPTextEncode': {
'prompt': ('inputs', 'text'),
'clip': ('inputs', 'clip'),
'is_negative': ('is_negative',)
},
'CheckpointLoaderSimple': {
'model': ('inputs', 'ckpt_name')
},
'VAELoader': {
'vae': ('inputs', 'vae_name')
},
'LoraLoader': {
'lora_name': ('inputs', 'lora_name'),
'strength_model': ('inputs', 'strength_model'),
'strength_clip': ('inputs', 'strength_clip')
}
}
# Extract parameters from nodes
for node_id, node in nodes.items():
class_type = node.get('class_type')
if class_type in node_parameter_map:
param_map = node_parameter_map[class_type]
for param_name, path in param_map.items():
# Navigate path to extract parameter
value = node
for part in path:
if isinstance(value, dict) and part in value:
value = value[part]
else:
value = None
break
if value is not None:
parameters[param_name] = value
# Special handling for negative prompts
if class_type == 'CLIPTextEncode' and node.get('is_negative'):
if 'prompt' in parameters:
parameters['negative_prompt'] = parameters.pop('prompt')
return parameters
def _prepare_xmp_packet(self, metadata: Dict[str, Any]) -> str:
"""
Prepare XMP packet from metadata
Args:
metadata: Metadata to convert to XMP
Returns:
str: XMP packet
"""
try:
import xml.etree.ElementTree as ET
# Create base XMP structure
xmpmeta = ET.Element("{adobe:ns:meta/}xmpmeta")
# Add namespaces
for prefix, uri in NamespaceManager.NAMESPACES.items():
ET.register_namespace(prefix, uri)
xmpmeta.set(f"xmlns:{prefix}", uri)
# Create RDF element
rdf = ET.SubElement(xmpmeta, f"{{{NamespaceManager.NAMESPACES['rdf']}}}RDF")
# Create Description element
description = ET.SubElement(rdf, f"{{{NamespaceManager.NAMESPACES['rdf']}}}Description")
description.set(f"{{{NamespaceManager.NAMESPACES['rdf']}}}about", self.resource_about or "")
# Add basic metadata
if 'basic' in metadata:
self._add_basic_metadata_to_xmp(description, metadata['basic'])
# Add analysis data
if 'analysis' in metadata:
self._add_analysis_metadata_to_xmp(description, metadata['analysis'])
# Add AI info
if 'ai_info' in metadata:
self._add_ai_metadata_to_xmp(description, metadata['ai_info'])
# Add regions
if 'regions' in metadata:
self._add_region_metadata_to_xmp(description, metadata['regions'])
# Format with indentation
self._indent_xml(xmpmeta)
# Convert to string
xmp_str = ET.tostring(xmpmeta, encoding='unicode')
# Add XMP packet wrapper
return f'<?xpacket begin="" id="W5M0MpCehiHzreSzNTczkc9d"?>\n{xmp_str}\n<?xpacket end="w"?>'
except Exception as e:
self.log(f"XMP packet preparation failed: {str(e)}", level="ERROR", error=e)
return ""
def _parse_xmp_packet(self, xmp_data: str) -> Dict[str, Any]:
"""
Parse XMP packet to metadata
Args:
xmp_data: XMP packet as string
Returns:
dict: Parsed metadata
"""
try:
from ..utils.xml_tools import XMLTools
return XMLTools.xmp_to_dict(xmp_data)
except Exception as e:
self.log(f"XMP packet parsing failed: {str(e)}", level="ERROR", error=e)
return {}
def _merge_xmp_metadata(self, target: Dict[str, Any], source: Dict[str, Any]) -> None:
"""
Merge XMP metadata into target
Args:
target: Target metadata to update
source: Source metadata to merge
"""
# Basic merge, can be enhanced with more specific merging rules
for section, data in source.items():
if section not in target:
target[section] = data
elif isinstance(data, dict) and isinstance(target[section], dict):
# Recursive merge for nested dictionaries
for key, value in data.items():
if key not in target[section]:
target[section][key] = value
elif isinstance(value, dict) and isinstance(target[section][key], dict):
# Deep merge for nested dictionaries
self._merge_xmp_metadata(target[section][key], value)
else:
# Replace with newer value
target[section][key] = value
else:
# Replace with newer value
target[section] = data
def _indent_xml(self, elem, level=0):
"""
Add proper indentation to XML for readability
Args:
elem: XML element
level: Indentation level
"""
i = "\n" + level * " "
if len(elem):
if not elem.text or not elem.text.strip():
elem.text = i + " "
if not elem.tail or not elem.tail.strip():
elem.tail = i
for elem in elem:
self._indent_xml(elem, level + 1)
if not elem.tail or not elem.tail.strip():
elem.tail = i
else:
if level and (not elem.tail or not elem.tail.strip()):
elem.tail = i
def _convert_to_exiftool_format(self, metadata: Dict[str, Any]) -> Dict[str, Any]:
"""
Convert our metadata format to ExifTool format
Args:
metadata: Our metadata format
Returns:
dict: ExifTool format
"""
exiftool_meta = {}
# Basic metadata
if 'basic' in metadata:
basic = metadata['basic']
if 'title' in basic:
exiftool_meta['XMP:Title'] = basic['title']
exiftool_meta['IPTC:ObjectName'] = basic['title']
if 'description' in basic:
exiftool_meta['XMP:Description'] = basic['description']
exiftool_meta['IPTC:Caption-Abstract'] = basic['description']
if 'keywords' in basic:
keywords = basic['keywords']
if isinstance(keywords, (list, set, tuple)):
keywords = list(keywords)
elif isinstance(keywords, str):
keywords = [k.strip() for k in keywords.split(',')]
else:
keywords = [str(keywords)]
exiftool_meta['XMP:Subject'] = keywords
exiftool_meta['IPTC:Keywords'] = keywords
if 'rating' in basic:
exiftool_meta['XMP:Rating'] = basic['rating']
# Analysis data
if 'analysis' in metadata:
for analysis_type, data in metadata['analysis'].items():
if isinstance(data, dict):
for key, value in data.items():
# Handle nested dictionaries
if isinstance(value, dict):
for subkey, subvalue in value.items():
exiftool_meta[f'XMP-eiqa:{analysis_type}.{key}.{subkey}'] = subvalue
else:
exiftool_meta[f'XMP-eiqa:{analysis_type}.{key}'] = value
else:
exiftool_meta[f'XMP-eiqa:{analysis_type}'] = data
# AI info
if 'ai_info' in metadata:
for key, value in metadata['ai_info'].items():
if isinstance(value, dict):
for subkey, subvalue in value.items():
# Handle nested dictionaries
if isinstance(subvalue, dict):
for subsubkey, subsubvalue in subvalue.items():
exiftool_meta[f'XMP-ai:{key}.{subkey}.{subsubkey}'] = subsubvalue
else:
exiftool_meta[f'XMP-ai:{key}.{subkey}'] = subvalue
else:
exiftool_meta[f'XMP-ai:{key}'] = value
# Regions - ExifTool supports MWG regions
if 'regions' in metadata and 'faces' in metadata['regions']:
# We'll rely on our XMP handling for regions
# This would need more complex mapping for ExifTool
pass
return exiftool_meta
def _convert_from_exiftool_format(self, exiftool_data: Dict[str, Any]) -> Dict[str, Any]:
"""
Convert ExifTool format to our metadata format
Args:
exiftool_data: ExifTool data
Returns:
dict: Our metadata format
"""
result = {
'basic': {},
'analysis': {},
'ai_info': {}
}
# Process ExifTool data
for key, value in exiftool_data.items():
# Extract namespace prefix and field name
parts = key.split(':', 1)
if len(parts) != 2:
continue
prefix, field = parts
# Basic metadata
if prefix in ['XMP', 'IPTC', 'EXIF']:
if field == 'Title' or field == 'ObjectName':
result['basic']['title'] = value
elif field == 'Description' or field == 'Caption-Abstract':
result['basic']['description'] = value
elif field == 'Subject' or field == 'Keywords':
if isinstance(value, str):
result['basic']['keywords'] = [value]
else:
result['basic']['keywords'] = value
elif field == 'Rating':
result['basic']['rating'] = value
# Analysis data (eiqa namespace)
elif prefix == 'XMP-eiqa':
# Handle analysis data with nested structure
field_parts = field.split('.')
analysis_type = field_parts[0]
if analysis_type not in result['analysis']:
result['analysis'][analysis_type] = {}
if len(field_parts) == 1:
# Direct field
result['analysis'][analysis_type] = value
elif len(field_parts) == 2:
# One level of nesting
result['analysis'][analysis_type][field_parts[1]] = value
elif len(field_parts) >= 3:
# Multiple levels of nesting
current = result['analysis'][analysis_type]
for part in field_parts[1:-1]:
if part not in current:
current[part] = {}
current = current[part]
current[field_parts[-1]] = value
# AI info (ai namespace)
elif prefix == 'XMP-ai':
# Handle AI info with nested structure
field_parts = field.split('.')
ai_type = field_parts[0]
if ai_type not in result['ai_info']:
result['ai_info'][ai_type] = {}
if len(field_parts) == 1:
# Direct field
result['ai_info'][ai_type] = value
elif len(field_parts) == 2:
# One level of nesting
result['ai_info'][ai_type][field_parts[1]] = value
elif len(field_parts) >= 3:
# Multiple levels of nesting
current = result['ai_info'][ai_type]
for part in field_parts[1:-1]:
if part not in current:
current[part] = {}
current = current[part]
current[field_parts[-1]] = value
return result
def _prepare_basic_metadata(self, basic_data: Dict[str, Any], existing_iptc: Dict[str, Any]) -> Dict[str, Dict[str, Any]]:
"""
Prepare basic metadata for various formats
Args:
basic_data: Basic metadata
existing_iptc: Existing IPTC metadata
Returns:
dict: Metadata for XMP, IPTC, and EXIF
"""
result = {
'xmp': {},
'iptc': {},
'exif': {}
}
# Title
if 'title' in basic_data:
title = basic_data['title']
# XMP with language alternative
result['xmp']['Xmp.dc.title'] = {'x-default': title}
# IPTC
result['iptc']['Iptc.Application2.ObjectName'] = title
# EXIF
result['exif']['Exif.Image.ImageDescription'] = title
# Description - handle potential truncation issues
if 'description' in basic_data:
desc = basic_data['description']
# Check for long descriptions - increase limit from 2000 to 4000
if len(desc) > 4000:
# For XMP with language alternative - truncate with indicator
truncated_desc = desc[:3997] + "..."
result['xmp']['Xmp.dc.description'] = {'x-default': truncated_desc}
# For IPTC - has stricter limits, truncate more aggressively
iptc_desc = desc[:512] + "..." if len(desc) > 512 else desc
result['iptc']['Iptc.Application2.Caption'] = iptc_desc
# Store full description in a sidecar if needed
self.log(f"Description truncated for XMP embedding. Original length: {len(desc)}", level="DEBUG")
else:
# XMP with language alternative
result['xmp']['Xmp.dc.description'] = {'x-default': desc}
# IPTC
result['iptc']['Iptc.Application2.Caption'] = desc
# Keywords
if 'keywords' in basic_data:
keywords = basic_data['keywords']
# Ensure keywords is a list
if isinstance(keywords, str):
keywords = [k.strip() for k in keywords.split(',')]
elif not isinstance(keywords, (list, set, tuple)):
keywords = [str(keywords)]
# Ensure keywords is a list (not set)
keywords = list(keywords)
# XMP
result['xmp']['Xmp.dc.subject'] = keywords
# IPTC
result['iptc']['Iptc.Application2.Keywords'] = keywords
# Rating
if 'rating' in basic_data:
rating = basic_data['rating']
# XMP
result['xmp']['Xmp.xmp.Rating'] = rating
return result
def _prepare_analysis_metadata(self, analysis_data: Dict[str, Any], existing_xmp: Dict[str, Any]) -> Dict[str, Any]:
"""
Prepare analysis metadata for XMP
Args:
analysis_data: Analysis metadata
existing_xmp: Existing XMP metadata
Returns:
dict: XMP metadata
"""
result = {}
# Process each analysis type
for analysis_type, data in analysis_data.items():
# Skip empty data
if not data:
continue
# Handle different analysis types
if isinstance(data, dict):
for key, value in data.items():
# Handle nested dictionaries
if isinstance(value, dict):
for subkey, subvalue in value.items():
result[f'Xmp.eiqa.{analysis_type}.{key}.{subkey}'] = subvalue
else:
result[f'Xmp.eiqa.{analysis_type}.{key}'] = value
else:
result[f'Xmp.eiqa.{analysis_type}'] = data
return result
def _prepare_ai_metadata(self, ai_data: Dict[str, Any], existing_xmp: Dict[str, Any]) -> Dict[str, Any]:
"""
Prepare AI metadata for XMP with proper RDF structure for complex objects
Args:
ai_data: AI metadata
existing_xmp: Existing XMP metadata
Returns:
dict: XMP metadata
"""
result = {}
# IMPORTANT: Completely skip workflow data by default - it's too large for XMP
# Remove both workflow.data and generation.prompt which contain the full workflow
workflow_keys_to_remove = ['prompt', 'workflow', 'workflow_data']
# First, make a clean copy of the generation data without workflow
generation_data = {}
if 'generation' in ai_data:
generation = ai_data['generation']
# Copy only essential generation parameters, explicitly filtering out workflow data
for key, value in generation.items():
# Skip workflow-related fields and raw prompts which are too large
if key not in workflow_keys_to_remove and not key.endswith('_prompt_raw'):
generation_data[key] = value
# Process generation data with proper RDF structure
if generation_data:
# Create the base structure for ai namespace
prefix = 'Xmp.ai.'
# Process different sections with proper grouping
# Base model info
if 'base_model' in generation_data:
result.update(self._prepare_dict_for_xmp(f"{prefix}BaseModel", generation_data['base_model']))
# Sampling parameters
if 'sampling' in generation_data:
result.update(self._prepare_dict_for_xmp(f"{prefix}Sampling", generation_data['sampling']))
elif any(key in generation_data for key in ['sampler', 'steps', 'cfg_scale', 'seed']):
# If sampling section doesn't exist but individual parameters do, create it
sampling = {}
for key in ['sampler', 'scheduler', 'steps', 'cfg_scale', 'seed', 'denoise']:
if key in generation_data:
sampling[key] = generation_data[key]
if sampling:
result.update(self._prepare_dict_for_xmp(f"{prefix}Sampling", sampling))
# Flux parameters
if 'flux' in generation_data:
result.update(self._prepare_dict_for_xmp(f"{prefix}Flux", generation_data['flux']))
# Dimensions
if 'dimensions' in generation_data:
result.update(self._prepare_dict_for_xmp(f"{prefix}Dimensions", generation_data['dimensions']))
elif any(key in generation_data for key in ['width', 'height']):
# If dimensions section doesn't exist but individual parameters do, create it
dimensions = {}
for key in ['width', 'height', 'batch_size']:
if key in generation_data:
dimensions[key] = generation_data[key]
if dimensions:
result.update(self._prepare_dict_for_xmp(f"{prefix}Dimensions", dimensions))
# Modules
if 'modules' in generation_data:
modules = generation_data['modules']
# VAE
if 'vae' in modules:
result.update(self._prepare_dict_for_xmp(f"{prefix}VAE", modules['vae']))
# CLIP
if 'clip' in modules:
result.update(self._prepare_dict_for_xmp(f"{prefix}CLIP", modules['clip']))
# CLIP Vision
if 'clip_vision' in modules:
result.update(self._prepare_dict_for_xmp(f"{prefix}CLIPVision", modules['clip_vision']))
# LoRAs
if 'loras' in modules and modules['loras']:
# For LoRAs, use a Bag structure
if isinstance(modules['loras'], list):
result.update(self._prepare_list_for_xmp(f"{prefix}LoRAs", modules['loras']))
else:
# Single LoRA or dict
result.update(self._prepare_dict_for_xmp(f"{prefix}LoRAs", modules['loras']))
# ControlNets
if 'controlnets' in modules and modules['controlnets']:
result.update(self._prepare_list_for_xmp(f"{prefix}ControlNets", modules['controlnets']))
# Style Models
if 'style_models' in modules and modules['style_models']:
result.update(self._prepare_list_for_xmp(f"{prefix}StyleModels", modules['style_models']))
# IP Adapters
if 'ip_adapters' in modules and modules['ip_adapters']:
result.update(self._prepare_list_for_xmp(f"{prefix}IPAdapters", modules['ip_adapters']))
# Process any top-level parameters not already handled
for key, value in generation_data.items():
if key not in ['base_model', 'sampling', 'flux', 'dimensions', 'modules'] and not isinstance(value, (dict, list)):
result[f"{prefix}{key}"] = value
# Add timestamp if missing
if 'timestamp' not in generation_data:
import datetime
result[f"{prefix}timestamp"] = datetime.datetime.now().isoformat()
# Copy key values to the dc:subject (keywords) for searchability
important_values = []
# Extract model name
if 'base_model' in generation_data and isinstance(generation_data['base_model'], dict) and 'unet' in generation_data['base_model']:
important_values.append(generation_data['base_model']['unet'])
elif 'model' in generation_data:
important_values.append(generation_data['model'])
# Extract sampler info
if 'sampling' in generation_data and isinstance(generation_data['sampling'], dict) and 'sampler' in generation_data['sampling']:
important_values.append(f"sampler_{generation_data['sampling']['sampler']}")
elif 'sampler' in generation_data:
important_values.append(f"sampler_{generation_data['sampler']}")
# Extract LoRA names
if 'modules' in generation_data and 'loras' in generation_data['modules']:
loras = generation_data['modules']['loras']
if isinstance(loras, list):
for lora in loras:
if isinstance(lora, dict) and 'name' in lora:
important_values.append(f"lora_{lora['name']}")
elif isinstance(loras, dict) and 'name' in loras:
important_values.append(f"lora_{loras['name']}")
elif 'loras' in generation_data:
loras = generation_data['loras']
if isinstance(loras, list):
for lora in loras:
if isinstance(lora, dict) and 'name' in lora:
important_values.append(f"lora_{lora['name']}")
# Add important values to dc:instructions instead of dc:subject
prefix_dc = 'Xmp.dc.'
# First, preserve any existing subject/keywords exactly as they are
if 'subject' in existing_xmp:
result[f"{prefix_dc}subject"] = existing_xmp['subject']
# Now add the AI values to instructions
if 'instructions' in existing_xmp:
# Get existing instructions
existing_instructions = []
if isinstance(existing_xmp['instructions'], list):
existing_instructions = existing_xmp['instructions']
else:
existing_instructions = [existing_xmp['instructions']]
# Add new items without duplicating
merged_instructions = existing_instructions.copy()
for value in important_values:
# Clean up file paths for better searchability
if '\\' in value:
value = value.replace('\\', '_')
if value not in merged_instructions:
merged_instructions.append(value)
# Update instructions
result[f"{prefix_dc}instructions"] = merged_instructions
else:
# Create new instructions
# Clean up file paths
cleaned_values = []
for value in important_values:
if '\\' in value:
cleaned_values.append(value.replace('\\', '_'))
else:
cleaned_values.append(value)
result[f"{prefix_dc}instructions"] = cleaned_values
# Handle workflow_info with proper structure (not the workflow itself)
if 'workflow_info' in ai_data:
prefix = 'Xmp.ai.WorkflowInfo.'
workflow_info = ai_data['workflow_info']
for key, value in workflow_info.items():
if not isinstance(value, (dict, list)) and value is not None:
# Simple value, add directly (with size limit)
if isinstance(value, str) and len(value) > 2000:
result[f"{prefix}{key}"] = value[:1997] + "..."
else:
result[f"{prefix}{key}"] = value
elif isinstance(value, dict):
# Handle dictionary as rdf:Description
result.update(self._prepare_dict_for_xmp(f"{prefix}{key}", value))
elif isinstance(value, list):
# Handle list as rdf:Bag
result.update(self._prepare_list_for_xmp(f"{prefix}{key}", value))
# Copy key parameters to Photoshop namespace for better compatibility
photoshop_prefix = 'Xmp.photoshop.'
# Model name
if 'base_model' in generation_data and isinstance(generation_data['base_model'], dict) and 'unet' in generation_data['base_model']:
result[f"{photoshop_prefix}AI_model"] = generation_data['base_model']['unet']
# Sampling parameters
if 'sampling' in generation_data and isinstance(generation_data['sampling'], dict):
sampling = generation_data['sampling']
for src, dest in [('sampler', 'AI_sampler'), ('steps', 'AI_steps'),
('cfg_scale', 'AI_cfg_scale'), ('seed', 'AI_seed')]:
if src in sampling:
result[f"{photoshop_prefix}{dest}"] = sampling[src]
# Explicitly remove any workflow related fields
keys_to_remove = []
for key in result:
if any(wk in key.lower() for wk in ['workflow.data', 'generation.prompt', 'workflow_data']):
keys_to_remove.append(key)
for key in keys_to_remove:
result.pop(key, None)
return result
def _prepare_dict_for_xmp(self, prefix: str, data: Dict[str, Any]) -> Dict[str, Any]:
"""
Prepare dictionary for XMP as rdf:Description
Args:
prefix: XMP property prefix
data: Dictionary data
Returns:
dict: XMP representation
"""
result = {}
# Create rdf:Description structure
description = {}
for key, value in data.items():
if not isinstance(value, (dict, list)) and value is not None:
# Simple value
description[key] = value
elif isinstance(value, dict):
# Nested dictionary - create a new property with its own rdf:Description
nested_prefix = f"{prefix}.{key}"
result.update(self._prepare_dict_for_xmp(nested_prefix, value))
elif isinstance(value, list):
# List - create a property with rdf:Bag
nested_prefix = f"{prefix}.{key}"
result.update(self._prepare_list_for_xmp(nested_prefix, value))
# Only add the description if it has content
if description:
result[prefix] = {"rdf:Description": description}
return result
def _prepare_list_for_xmp(self, prefix: str, data: List[Any]) -> Dict[str, Any]:
"""
Prepare list for XMP as rdf:Bag
Args:
prefix: XMP property prefix
data: List data
Returns:
dict: XMP representation
"""
result = {}
# Skip empty lists
if not data:
return result
# Check if this is a list of simple values or complex objects
has_complex_items = any(isinstance(item, (dict, list)) for item in data)
if has_complex_items:
# Complex list - each item can be a dictionary or another list
bag_items = []
for item in data:
if isinstance(item, dict):
# Dictionary item - create rdf:Description
desc = {}
for k, v in item.items():
if not isinstance(v, (dict, list)) and v is not None:
desc[k] = v
if desc:
bag_items.append({"rdf:Description": desc})
elif not isinstance(item, (dict, list)) and item is not None:
# Simple value
bag_items.append(item)
if bag_items:
result[prefix] = {"rdf:Bag": bag_items}
else:
# Simple list - all values are scalar
result[prefix] = {"rdf:Bag": data}
return result
def _prepare_region_metadata(self, region_data: Dict[str, Any], existing_xmp: Dict[str, Any]) -> Dict[str, Any]:
"""
Prepare region metadata for XMP following MWG standard
Args:
region_data: Region metadata
existing_xmp: Existing XMP metadata
Returns:
dict: XMP metadata
"""
# This is a complex method that would require implementing the MWG region standard
# We'll rely on the detailed implementation in the XMP handler
# and just create a minimal implementation here
result = {}
# Check if regions exist
if 'faces' not in region_data or not region_data['faces']:
return result
# Create region list
region_list = []
# Process face regions
for face in region_data['faces']:
# Create region
region = {
'mwg-rs:Type': face.get('type', 'Face'),
'mwg-rs:Name': face.get('name', 'Face')
}
# Add area
if 'area' in face:
area = face['area']
region['mwg-rs:Area'] = {
'stArea:x': str(area.get('x', 0)),
'stArea:y': str(area.get('y', 0)),
'stArea:w': str(area.get('w', 0)),
'stArea:h': str(area.get('h', 0))
}
# Add extensions if present
if 'extensions' in face and 'eiqa' in face['extensions']:
region['mwg-rs:Extensions'] = face['extensions']
region_list.append(region)
# Create XMP structure
if region_list:
result['Xmp.mwg-rs.Regions'] = {
'mwg-rs:RegionList': {'rdf:Bag': region_list}
}
# Add summary if present
if 'summary' in region_data:
for key, value in region_data['summary'].items():
result[f'Xmp.eiqa.face_summary.{key}'] = value
return result
def _extract_basic_metadata(self, result: Dict[str, Any], xmp_data: Dict[str, Any],
iptc_data: Dict[str, Any], exif_data: Dict[str, Any]) -> None:
"""
Extract basic metadata from XMP, IPTC, and EXIF
Args:
result: Result dictionary to update
xmp_data: XMP metadata
iptc_data: IPTC metadata
exif_data: EXIF metadata
"""
# Initialize basic section
if 'basic' not in result:
result['basic'] = {}
# Title (prefer XMP > IPTC > EXIF)
if 'Xmp.dc.title' in xmp_data:
title_data = xmp_data['Xmp.dc.title']
if isinstance(title_data, dict) and 'x-default' in title_data:
result['basic']['title'] = title_data['x-default']
else:
result['basic']['title'] = str(title_data)
elif 'Iptc.Application2.ObjectName' in iptc_data:
result['basic']['title'] = iptc_data['Iptc.Application2.ObjectName']
elif 'Exif.Image.ImageDescription' in exif_data:
result['basic']['description'] = exif_data['Exif.Image.ImageDescription']
# Description (prefer XMP > IPTC)
if 'Xmp.dc.description' in xmp_data:
desc_data = xmp_data['Xmp.dc.description']
if isinstance(desc_data, dict) and 'x-default' in desc_data:
result['basic']['description'] = desc_data['x-default']
else:
result['basic']['description'] = str(desc_data)
elif 'Iptc.Application2.Caption' in iptc_data:
result['basic']['description'] = iptc_data['Iptc.Application2.Caption']
# Keywords (prefer XMP > IPTC)
keywords = set()
if 'Xmp.dc.subject' in xmp_data:
subject_data = xmp_data['Xmp.dc.subject']
if isinstance(subject_data, list):
keywords.update(subject_data)
else:
keywords.add(str(subject_data))
if 'Iptc.Application2.Keywords' in iptc_data:
iptc_keywords = iptc_data['Iptc.Application2.Keywords']
if isinstance(iptc_keywords, list):
keywords.update(iptc_keywords)
else:
keywords.add(str(iptc_keywords))
if keywords:
result['basic']['keywords'] = list(keywords)
# Rating
if 'Xmp.xmp.Rating' in xmp_data:
try:
result['basic']['rating'] = int(xmp_data['Xmp.xmp.Rating'])
except (ValueError, TypeError):
pass
# Creation date
for date_field in ['Xmp.xmp.CreateDate', 'Exif.Photo.DateTimeOriginal', 'Exif.Image.DateTime']:
if date_field in xmp_data:
result['basic']['create_date'] = xmp_data[date_field]
break
elif date_field.replace('Xmp.', 'Exif.') in exif_data:
result['basic']['create_date'] = exif_data[date_field.replace('Xmp.', 'Exif.')]
break
def _extract_analysis_metadata(self, result: Dict[str, Any], xmp_data: Dict[str, Any]) -> None:
"""
Extract analysis metadata from XMP
Args:
result: Result dictionary to update
xmp_data: XMP metadata
"""
# Initialize analysis section
if 'analysis' not in result:
result['analysis'] = {}
# Process all eiqa namespace data
eiqa_prefix = 'Xmp.eiqa.'
# Keep track of which analysis types we've processed
processed_types = set()
# Find all eiqa fields
for key in xmp_data:
if key.startswith(eiqa_prefix):
# Extract analysis type (first component after eiqa)
parts = key[len(eiqa_prefix):].split('.')
if not parts:
continue
analysis_type = parts[0]
processed_types.add(analysis_type)
# Initialize analysis type if needed
if analysis_type not in result['analysis']:
result['analysis'][analysis_type] = {}
# Process field based on depth
if len(parts) == 1:
# Direct field
result['analysis'][analysis_type] = xmp_data[key]
elif len(parts) == 2:
# One level of nesting
result['analysis'][analysis_type][parts[1]] = xmp_data[key]
elif len(parts) >= 3:
# Multiple levels of nesting
current = result['analysis'][analysis_type]
for part in parts[1:-1]:
if part not in current:
current[part] = {}
current = current[part]
current[parts[-1]] = xmp_data[key]
# Process special case for PyIQA data
if 'pyiqa' in processed_types:
# Make sure we have the proper nested structure
if isinstance(result['analysis'].get('pyiqa'), dict):
# Already structured properly
pass
else:
# Create proper structure
pyiqa_data = {}
for key in xmp_data:
if key.startswith(eiqa_prefix + 'pyiqa.'):
model_name = key[len(eiqa_prefix + 'pyiqa.'):]
pyiqa_data[model_name] = xmp_data[key]
if pyiqa_data:
result['analysis']['pyiqa'] = pyiqa_data
def _extract_ai_metadata(self, result: Dict[str, Any], xmp_data: Dict[str, Any]) -> None:
"""
Extract AI metadata from XMP
Args:
result: Result dictionary to update
xmp_data: XMP metadata
"""
# Initialize ai_info section
if 'ai_info' not in result:
result['ai_info'] = {}
# Process all ai namespace data
ai_prefix = 'Xmp.ai.'
# Keep track of which AI data types we've processed
processed_types = set()
# Find all ai fields
for key in xmp_data:
if key.startswith(ai_prefix):
# Extract AI data type (first component after ai)
parts = key[len(ai_prefix):].split('.')
if not parts:
continue
ai_type = parts[0]
processed_types.add(ai_type)
# Initialize AI type if needed
if ai_type not in result['ai_info']:
result['ai_info'][ai_type] = {}
# Process field based on depth
if len(parts) == 1:
# Direct field
result['ai_info'][ai_type] = xmp_data[key]
elif len(parts) == 2:
# One level of nesting
result['ai_info'][ai_type][parts[1]] = xmp_data[key]
elif len(parts) >= 3:
# Multiple levels of nesting
current = result['ai_info'][ai_type]
for part in parts[1:-1]:
if part not in current:
current[part] = {}
current = current[part]
current[parts[-1]] = xmp_data[key]
# Process special case for generation data
if 'generation' in processed_types:
# Handle loras list if it exists as RDF sequence
if 'Xmp.ai.generation.loras' in xmp_data:
loras_data = xmp_data['Xmp.ai.generation.loras']
if isinstance(loras_data, dict) and 'rdf:Seq' in loras_data:
loras_list = []
for item in loras_data['rdf:Seq']:
if isinstance(item, dict):
lora = {
'name': item.get('ai:name', ''),
'strength': float(item.get('ai:strength', 1.0))
}
loras_list.append(lora)
else:
loras_list.append({'name': str(item)})
result['ai_info']['generation']['loras'] = loras_list
# Process workflow data
if 'workflow' in processed_types:
workflow_data = xmp_data.get('Xmp.ai.workflow')
if workflow_data:
try:
# Try to parse as JSON
import json
if isinstance(workflow_data, str):
result['ai_info']['workflow'] = json.loads(workflow_data)
else:
result['ai_info']['workflow'] = workflow_data
except (json.JSONDecodeError, TypeError):
# Store as is if parsing fails
result['ai_info']['workflow'] = workflow_data
def _extract_region_metadata(self, result: Dict[str, Any], xmp_data: Dict[str, Any]) -> None:
"""
Extract region metadata from XMP
Args:
result: Result dictionary to update
xmp_data: XMP metadata
"""
# Check if regions exist
if 'Xmp.mwg-rs.Regions' not in xmp_data:
return
# Initialize regions section
if 'regions' not in result:
result['regions'] = {
'summary': {
'face_count': 0,
'detector_type': None
},
'faces': [],
'areas': []
}
# Get region list
regions_data = xmp_data['Xmp.mwg-rs.Regions']
if not isinstance(regions_data, dict) or 'mwg-rs:RegionList' not in regions_data:
return
region_list = regions_data['mwg-rs:RegionList']
if not isinstance(region_list, dict) or 'rdf:Bag' not in region_list:
return
bag_items = region_list['rdf:Bag']
if not isinstance(bag_items, list):
return
# Process each region
for item in bag_items:
if not isinstance(item, dict):
continue
# Get region type
region_type = item.get('mwg-rs:Type', '')
# Process based on type
if region_type == 'Face':
# Extract face data
face = {
'type': 'Face',
'name': item.get('mwg-rs:Name', 'Face')
}
# Extract area
area_data = item.get('mwg-rs:Area')
if isinstance(area_data, dict):
face['area'] = {
'x': float(area_data.get('stArea:x', 0)),
'y': float(area_data.get('stArea:y', 0)),
'w': float(area_data.get('stArea:w', 0)),
'h': float(area_data.get('stArea:h', 0))
}
else:
# Skip face without area
continue
# Extract extensions
extensions_data = item.get('mwg-rs:Extensions')
if isinstance(extensions_data, dict):
face['extensions'] = {'eiqa': {}}
# Get face analysis data
eiqa_data = extensions_data.get('eiqa:FaceAnalysis')
if isinstance(eiqa_data, dict):
face['extensions']['eiqa']['face_analysis'] = eiqa_data
# Add face to list
result['regions']['faces'].append(face)
elif region_type:
# Extract area data
area = {
'type': region_type,
'name': item.get('mwg-rs:Name', region_type)
}
# Extract area
area_data = item.get('mwg-rs:Area')
if isinstance(area_data, dict):
area['area'] = {
'x': float(area_data.get('stArea:x', 0)),
'y': float(area_data.get('stArea:y', 0)),
'w': float(area_data.get('stArea:w', 0)),
'h': float(area_data.get('stArea:h', 0))
}
else:
# Skip area without coordinates
continue
# Add area to list
result['regions']['areas'].append(area)
# Update summary
result['regions']['summary']['face_count'] = len(result['regions']['faces'])
# Get detector type from face analysis summary if present
eiqa_summary = xmp_data.get('Xmp.eiqa.face_summary', {})
if isinstance(eiqa_summary, dict) and 'detector_type' in eiqa_summary:
result['regions']['summary']['detector_type'] = eiqa_summary['detector_type']
def _add_basic_metadata_to_xmp(self, desc: ET.Element, basic_data: Dict[str, Any]) -> None:
"""
Add basic metadata to XMP Description element following MWG standards and Adobe specifications
Args:
desc: Description element
basic_data: Basic metadata
"""
# Title with language alternative - Adobe standard approach
if 'title' in basic_data:
title_attr = ET.SubElement(desc, f"{{{self.XMP_NS['photoshop']}}}ObjectName")
title_attr.text = str(basic_data['title'])
title_elem = ET.SubElement(desc, f"{{{self.XMP_NS['dc']}}}title")
alt = ET.SubElement(title_elem, f"{{{self.XMP_NS['rdf']}}}Alt")
li = ET.SubElement(alt, f"{{{self.XMP_NS['rdf']}}}li")
li.set(f"{{{self.XMP_NS['xml']}}}lang", "x-default")
li.text = str(basic_data['title'])
# Add additional title fields for compatibility
if 'headline' not in basic_data:
headline_elem = ET.SubElement(desc, f"{{{self.XMP_NS['photoshop']}}}Headline")
headline_elem.text = str(basic_data['title'])
# Description with language alternative - Adobe standard approach
if 'description' in basic_data:
desc_attr = ET.SubElement(desc, f"{{{self.XMP_NS['photoshop']}}}Caption-Abstract")
desc_attr.text = str(basic_data['description'])
desc_elem = ET.SubElement(desc, f"{{{self.XMP_NS['dc']}}}description")
alt = ET.SubElement(desc_elem, f"{{{self.XMP_NS['rdf']}}}Alt")
li = ET.SubElement(alt, f"{{{self.XMP_NS['rdf']}}}li")
li.set(f"{{{self.XMP_NS['xml']}}}lang", "x-default")
li.text = str(basic_data['description'])
# Add caption field for compatibility
if 'caption' not in basic_data:
caption_elem = ET.SubElement(desc, f"{{{self.XMP_NS['photoshop']}}}Caption")
caption_elem.text = str(basic_data['description'])
# Creator as sequence
if 'creator' in basic_data:
# For IPTC-IIM compatibility - this is critical for Adobe
auth_attr = ET.SubElement(desc, f"{{{self.XMP_NS['photoshop']}}}Author")
auth_attr.text = str(basic_data['creator'])
# For IPTC-IIM compatibility - Photoshop also looks at this
byline_attr = ET.SubElement(desc, f"{{{self.XMP_NS['photoshop']}}}Byline")
byline_attr.text = str(basic_data['creator'])
# Add standard XMP creator with sequence structure
creator_elem = ET.SubElement(desc, f"{{{self.XMP_NS['dc']}}}creator")
seq = ET.SubElement(creator_elem, f"{{{self.XMP_NS['rdf']}}}Seq")
li = ET.SubElement(seq, f"{{{self.XMP_NS['rdf']}}}li")
li.text = str(basic_data['creator'])
# Rights/Copyright with language alternative - Adobe standard approach
if 'copyright' in basic_data or 'rights' in basic_data:
copyright_text = basic_data.get('copyright', basic_data.get('rights', ''))
# For IPTC-IIM compatibility - these are what Adobe reads for copyright
notice_attr = ET.SubElement(desc, f"{{{self.XMP_NS['photoshop']}}}CopyrightNotice")
notice_attr.text = str(copyright_text)
status_attr = ET.SubElement(desc, f"{{{self.XMP_NS['photoshop']}}}CopyrightStatus")
status_attr.text = "Copyrighted"
# Standard language alternative structure for rights
rights_elem = ET.SubElement(desc, f"{{{self.XMP_NS['dc']}}}rights")
alt = ET.SubElement(rights_elem, f"{{{self.XMP_NS['rdf']}}}Alt")
li = ET.SubElement(alt, f"{{{self.XMP_NS['rdf']}}}li")
li.set(f"{{{self.XMP_NS['xml']}}}lang", "x-default")
li.text = str(copyright_text)
# Standard XMP Rights Management fields
marked_elem = ET.SubElement(desc, f"{{{self.XMP_NS['xmpRights']}}}Marked")
marked_elem.text = 'True'
# Add usage terms
terms_elem = ET.SubElement(desc, f"{{{self.XMP_NS['xmpRights']}}}UsageTerms")
terms_alt = ET.SubElement(terms_elem, f"{{{self.XMP_NS['rdf']}}}Alt")
terms_li = ET.SubElement(terms_alt, f"{{{self.XMP_NS['rdf']}}}li")
terms_li.set(f"{{{self.XMP_NS['xml']}}}lang", "x-default")
terms_li.text = str(copyright_text)
# Add copyright notice and status fields for compatibility
copyright_notice = ET.SubElement(desc, f"{{{self.XMP_NS['photoshop']}}}CopyrightNotice")
copyright_notice.text = str(copyright_text)
status_elem = ET.SubElement(desc, f"{{{self.XMP_NS['photoshop']}}}CopyrightStatus")
status_elem.text = 'Copyrighted'
# Rating - standard across Adobe products
if 'rating' in basic_data:
rating_elem = ET.SubElement(desc, f"{{{self.XMP_NS['xmp']}}}Rating")
rating_elem.text = str(basic_data['rating'])
# Keywords as bag - Adobe standard approach
if 'keywords' in basic_data and basic_data['keywords']:
keywords = basic_data['keywords']
# Ensure keywords is a list
if isinstance(keywords, str):
keywords = [k.strip() for k in keywords.split(',')]
elif not isinstance(keywords, (list, tuple, set)):
keywords = [str(keywords)]
# Create standard bag structure
subject_elem = ET.SubElement(desc, f"{{{self.XMP_NS['dc']}}}subject")
bag = ET.SubElement(subject_elem, f"{{{self.XMP_NS['rdf']}}}Bag")
# Add each keyword
for keyword in keywords:
li = ET.SubElement(bag, f"{{{self.XMP_NS['rdf']}}}li")
li.text = str(keyword)
# Add CreatorTool field - standard Adobe field for creation application
creator_tool = ET.SubElement(desc, f"{{{self.XMP_NS['xmp']}}}CreatorTool")
creator_tool.text = 'ComfyUI'
# Add creation date - critical for Adobe timeline functionality
if 'create_date' in basic_data:
create_date = ET.SubElement(desc, f"{{{self.XMP_NS['xmp']}}}CreateDate")
create_date.text = str(basic_data['create_date'])
else:
create_date = ET.SubElement(desc, f"{{{self.XMP_NS['xmp']}}}CreateDate")
create_date.text = datetime.datetime.now().isoformat()
# Add modification date
modify_date = ET.SubElement(desc, f"{{{self.XMP_NS['xmp']}}}ModifyDate")
modify_date.text = datetime.datetime.now().isoformat()
# Add metadata date - when metadata was last modified (MWG standard)
metadata_date = ET.SubElement(desc, f"{{{self.XMP_NS['xmp']}}}MetadataDate")
metadata_date.text = datetime.datetime.now().isoformat()
# Add XMP Media Management fields for document tracking (Adobe standard)
if 'project' in basic_data:
# Create a document ID based on the project name
document_id = f"xmp.did:project:{basic_data['project'].replace(' ', '_')}"
doc_id_elem = ET.SubElement(desc, f"{{{self.XMP_NS['xmpMM']}}}DocumentID")
doc_id_elem.text = document_id
# Add original document ID
orig_doc_id = ET.SubElement(desc, f"{{{self.XMP_NS['xmpMM']}}}OriginalDocumentID")
orig_doc_id.text = document_id
else:
# Create a unique ID if no project name
doc_id_elem = ET.SubElement(desc, f"{{{self.XMP_NS['xmpMM']}}}DocumentID")
doc_id_elem.text = f"xmp.did:{uuid.uuid4()}"
# Add instance ID for this specific version
instance_id = ET.SubElement(desc, f"{{{self.XMP_NS['xmpMM']}}}InstanceID")
instance_id.text = f"xmp.iid:{uuid.uuid4()}"
# Add text layers info if applicable (for formats like PSD, TIFF)
if 'text_layers' in basic_data and basic_data['text_layers']:
text_layers = ET.SubElement(desc, f"{{{self.XMP_NS['photoshop']}}}TextLayers")
layers_seq = ET.SubElement(text_layers, f"{{{self.XMP_NS['rdf']}}}Seq")
for layer in basic_data['text_layers']:
layer_li = ET.SubElement(layers_seq, f"{{{self.XMP_NS['rdf']}}}li")
layer_desc = ET.SubElement(layer_li, f"{{{self.XMP_NS['rdf']}}}Description")
layer_name = ET.SubElement(layer_desc, f"{{{self.XMP_NS['photoshop']}}}LayerName")
layer_name.text = layer.get('name', 'Text Layer')
layer_text = ET.SubElement(layer_desc, f"{{{self.XMP_NS['photoshop']}}}LayerText")
layer_text.text = layer.get('text', '')
def _add_analysis_metadata_to_xmp(self, desc: ET.Element, analysis_data: Dict[str, Any]) -> None:
"""
Add analysis metadata to XMP Description element
Args:
desc: Description element
analysis_data: Analysis metadata
"""
import xml.etree.ElementTree as ET
# Process each analysis type
for analysis_type, data in analysis_data.items():
# Skip empty data
if not data:
continue
# Create container for this analysis type
analysis_elem = ET.SubElement(desc, f"{{{self.XMP_NS['eiqa']}}}{analysis_type}")
analysis_desc = ET.SubElement(analysis_elem, f"{{{self.XMP_NS['rdf']}}}Description")
# Add data based on type
if isinstance(data, dict):
for key, value in data.items():
if isinstance(value, dict):
# Nested structure
sub_elem = ET.SubElement(analysis_desc, f"{{{self.XMP_NS['eiqa']}}}{key}")
sub_desc = ET.SubElement(sub_elem, f"{{{self.XMP_NS['rdf']}}}Description")
for subkey, subvalue in value.items():
if isinstance(subvalue, dict):
# Double nested structure
subsub_elem = ET.SubElement(sub_desc, f"{{{self.XMP_NS['eiqa']}}}{subkey}")
subsub_desc = ET.SubElement(subsub_elem, f"{{{self.XMP_NS['rdf']}}}Description")
for subsubkey, subsubvalue in subvalue.items():
subsubval_elem = ET.SubElement(subsub_desc, f"{{{self.XMP_NS['eiqa']}}}{subsubkey}")
subsubval_elem.text = str(subsubvalue)
elif isinstance(subvalue, (list, tuple, set)):
# List as Bag
subval_elem = ET.SubElement(sub_desc, f"{{{self.XMP_NS['eiqa']}}}{subkey}")
bag = ET.SubElement(subval_elem, f"{{{self.XMP_NS['rdf']}}}Bag")
for item in subvalue:
li = ET.SubElement(bag, f"{{{self.XMP_NS['rdf']}}}li")
li.text = str(item)
else:
# Simple value
subval_elem = ET.SubElement(sub_desc, f"{{{self.XMP_NS['eiqa']}}}{subkey}")
subval_elem.text = str(subvalue)
elif isinstance(value, (list, tuple, set)):
# List as Bag
val_elem = ET.SubElement(analysis_desc, f"{{{self.XMP_NS['eiqa']}}}{key}")
bag = ET.SubElement(val_elem, f"{{{self.XMP_NS['rdf']}}}Bag")
for item in value:
li = ET.SubElement(bag, f"{{{self.XMP_NS['rdf']}}}li")
li.text = str(item)
else:
# Simple value
val_elem = ET.SubElement(analysis_desc, f"{{{self.XMP_NS['eiqa']}}}{key}")
val_elem.text = str(value)
else:
# Simple value for entire analysis type
analysis_desc.text = str(data)
# Add timestamp if not present
if isinstance(data, dict) and 'timestamp' not in data:
timestamp_elem = ET.SubElement(analysis_desc, f"{{{self.XMP_NS['eiqa']}}}timestamp")
timestamp_elem.text = self.get_timestamp()
def _add_ai_metadata_to_xmp(self, desc: ET.Element, ai_data: Dict[str, Any]) -> None:
"""
Add AI metadata to XMP Description element
Args:
desc: Description element
ai_data: AI metadata
"""
import xml.etree.ElementTree as ET
# Handle generation data
if 'generation' in ai_data:
gen_data = ai_data['generation']
# Create generation container
gen_elem = ET.SubElement(desc, f"{{{self.XMP_NS['ai']}}}generation")
gen_desc = ET.SubElement(gen_elem, f"{{{self.XMP_NS['rdf']}}}Description")
# Add each generation parameter
for key, value in gen_data.items():
if key == 'loras' and isinstance(value, list):
# Handle LoRAs as a sequence
loras_elem = ET.SubElement(gen_desc, f"{{{self.XMP_NS['ai']}}}loras")
seq = ET.SubElement(loras_elem, f"{{{self.XMP_NS['rdf']}}}Seq")
for lora in value:
if isinstance(lora, dict):
li = ET.SubElement(seq, f"{{{self.XMP_NS['rdf']}}}li")
lora_desc = ET.SubElement(li, f"{{{self.XMP_NS['rdf']}}}Description")
name_elem = ET.SubElement(lora_desc, f"{{{self.XMP_NS['ai']}}}name")
name_elem.text = str(lora.get('name', ''))
strength_elem = ET.SubElement(lora_desc, f"{{{self.XMP_NS['ai']}}}strength")
strength_elem.text = str(lora.get('strength', 1.0))
else:
# Simple field
elem = ET.SubElement(gen_desc, f"{{{self.XMP_NS['ai']}}}{key}")
elem.text = str(value)
# Add timestamp if not present
if 'timestamp' not in gen_data:
timestamp_elem = ET.SubElement(gen_desc, f"{{{self.XMP_NS['ai']}}}timestamp")
timestamp_elem.text = self.get_timestamp()
# Handle workflow data
if 'workflow' in ai_data:
workflow_data = ai_data['workflow']
# Create workflow container
workflow_elem = ET.SubElement(desc, f"{{{self.XMP_NS['ai']}}}workflow")
# Store as JSON string
import json
workflow_elem.text = json.dumps(workflow_data)
# Handle other AI data
for key, value in ai_data.items():
if key not in ['generation', 'workflow']:
# Create container
ai_elem = ET.SubElement(desc, f"{{{self.XMP_NS['ai']}}}{key}")
if isinstance(value, dict):
# Complex structure
ai_desc = ET.SubElement(ai_elem, f"{{{self.XMP_NS['rdf']}}}Description")
for subkey, subvalue in value.items():
sub_elem = ET.SubElement(ai_desc, f"{{{self.XMP_NS['ai']}}}{subkey}")
sub_elem.text = str(subvalue)
else:
# Simple value
ai_elem.text = str(value)
def _add_region_metadata_to_xmp(self, desc: ET.Element, region_data: Dict[str, Any]) -> None:
"""
Add region metadata to XMP Description element following MWG standard
Args:
desc: Description element
region_data: Region metadata
"""
import xml.etree.ElementTree as ET
# Check if regions exist
if 'faces' not in region_data or not region_data['faces']:
return
# Create regions container
regions_elem = ET.SubElement(desc, f"{{{self.XMP_NS['mwg-rs']}}}Regions")
regions_desc = ET.SubElement(regions_elem, f"{{{self.XMP_NS['rdf']}}}Description")
# Create RegionList container
region_list = ET.SubElement(regions_desc, f"{{{self.XMP_NS['mwg-rs']}}}RegionList")
bag = ET.SubElement(region_list, f"{{{self.XMP_NS['rdf']}}}Bag")
# Process face regions
for face in region_data['faces']:
# Create list item
li = ET.SubElement(bag, f"{{{self.XMP_NS['rdf']}}}li")
face_desc = ET.SubElement(li, f"{{{self.XMP_NS['rdf']}}}Description")
# Add type and name
type_elem = ET.SubElement(face_desc, f"{{{self.XMP_NS['mwg-rs']}}}Type")
type_elem.text = face.get('type', 'Face')
name_elem = ET.SubElement(face_desc, f"{{{self.XMP_NS['mwg-rs']}}}Name")
name_elem.text = face.get('name', 'Face')
# Add area
if 'area' in face:
area = face['area']
area_elem = ET.SubElement(face_desc, f"{{{self.XMP_NS['mwg-rs']}}}Area")
# Add coordinates as attributes
area_elem.set(f"{{{self.XMP_NS['stArea']}}}x", str(area.get('x', 0)))
area_elem.set(f"{{{self.XMP_NS['stArea']}}}y", str(area.get('y', 0)))
area_elem.set(f"{{{self.XMP_NS['stArea']}}}w", str(area.get('w', 0)))
area_elem.set(f"{{{self.XMP_NS['stArea']}}}h", str(area.get('h', 0)))
# Add extensions if present
if 'extensions' in face and 'eiqa' in face['extensions']:
ext_elem = ET.SubElement(face_desc, f"{{{self.XMP_NS['mwg-rs']}}}Extensions")
ext_desc = ET.SubElement(ext_elem, f"{{{self.XMP_NS['rdf']}}}Description")
# Add face analysis
if 'face_analysis' in face['extensions']['eiqa']:
face_analysis = face['extensions']['eiqa']['face_analysis']
face_elem = ET.SubElement(ext_desc, f"{{{self.XMP_NS['eiqa']}}}face_analysis")
face_desc = ET.SubElement(face_elem, f"{{{self.XMP_NS['rdf']}}}Description")
# Add face analysis data
for key, value in face_analysis.items():
if isinstance(value, dict):
# Nested structure (e.g., emotion scores)
sub_elem = ET.SubElement(face_desc, f"{{{self.XMP_NS['eiqa']}}}{key}")
sub_desc = ET.SubElement(sub_elem, f"{{{self.XMP_NS['rdf']}}}Description")
for subkey, subvalue in value.items():
if subkey == 'scores' and isinstance(subvalue, dict):
# Handle scores as a nested structure
scores_elem = ET.SubElement(sub_desc, f"{{{self.XMP_NS['eiqa']}}}scores")
scores_desc = ET.SubElement(scores_elem, f"{{{self.XMP_NS['rdf']}}}Description")
for score_name, score_value in subvalue.items():
score_elem = ET.SubElement(scores_desc, f"{{{self.XMP_NS['eiqa']}}}{score_name}")
score_elem.text = str(score_value)
else:
# Simple value
val_elem = ET.SubElement(sub_desc, f"{{{self.XMP_NS['eiqa']}}}{subkey}")
val_elem.text = str(subvalue)
else:
# Simple value
val_elem = ET.SubElement(face_desc, f"{{{self.XMP_NS['eiqa']}}}{key}")
val_elem.text = str(value)
# Process area regions
for area in region_data.get('areas', []):
# Create list item
li = ET.SubElement(bag, f"{{{self.XMP_NS['rdf']}}}li")
area_desc = ET.SubElement(li, f"{{{self.XMP_NS['rdf']}}}Description")
# Add type and name
type_elem = ET.SubElement(area_desc, f"{{{self.XMP_NS['mwg-rs']}}}Type")
type_elem.text = area.get('type', 'Area')
name_elem = ET.SubElement(area_desc, f"{{{self.XMP_NS['mwg-rs']}}}Name")
name_elem.text = area.get('name', 'Area')
# Add area
area_elem = ET.SubElement(area_desc, f"{{{self.XMP_NS['mwg-rs']}}}Area")
# Add coordinates as attributes
if 'area' in area:
area_coords = area['area']
area_elem.set(f"{{{self.XMP_NS['stArea']}}}x", str(area_coords.get('x', 0)))
area_elem.set(f"{{{self.XMP_NS['stArea']}}}y", str(area_coords.get('y', 0)))
area_elem.set(f"{{{self.XMP_NS['stArea']}}}w", str(area_coords.get('w', 0)))
area_elem.set(f"{{{self.XMP_NS['stArea']}}}h", str(area_coords.get('h', 0)))
else:
# Handle direct coordinates if not in 'area' subfield
area_elem.set(f"{{{self.XMP_NS['stArea']}}}x", str(area.get('x', 0)))
area_elem.set(f"{{{self.XMP_NS['stArea']}}}y", str(area.get('y', 0)))
area_elem.set(f"{{{self.XMP_NS['stArea']}}}w", str(area.get('w', 0)))
area_elem.set(f"{{{self.XMP_NS['stArea']}}}h", str(area.get('h', 0)))
# Add extensions if present
if 'extensions' in area:
ext_elem = ET.SubElement(area_desc, f"{{{self.XMP_NS['mwg-rs']}}}Extensions")
ext_desc = ET.SubElement(ext_elem, f"{{{self.XMP_NS['rdf']}}}Description")
# Add EIQA extensions
if 'eiqa' in area['extensions']:
for key, value in area['extensions']['eiqa'].items():
if isinstance(value, dict):
# Handle nested structure
sub_elem = ET.SubElement(ext_desc, f"{{{self.XMP_NS['eiqa']}}}{key}")
sub_desc = ET.SubElement(sub_elem, f"{{{self.XMP_NS['rdf']}}}Description")
for subkey, subvalue in value.items():
sub_item = ET.SubElement(sub_desc, f"{{{self.XMP_NS['eiqa']}}}{subkey}")
sub_item.text = str(subvalue)
else:
# Simple value
elem = ET.SubElement(ext_desc, f"{{{self.XMP_NS['eiqa']}}}{key}")
elem.text = str(value)
def cleanup(self):
"""Clean up resources used by the handler"""
try:
# Close any open image if present
if self._current_metadata is not None:
try:
if hasattr(self._current_metadata, 'close'):
self._current_metadata.close()
except Exception as e:
self.log(f"Error closing metadata object: {str(e)}", level="WARNING")
self._current_metadata = None
except Exception as e:
self.log(f"Cleanup error: {str(e)}", level="ERROR")
def _cleanup_current_metadata(self):
"""Helper to clean up current metadata object"""
if self._current_metadata is not None:
try:
if hasattr(self._current_metadata, 'close'):
self._current_metadata.close()
except Exception:
pass
self._current_metadata = None