467 lines
17 KiB
Python
467 lines
17 KiB
Python
"""
|
|
MetadataQuery Utility - March 2025
|
|
Description: Utility for querying images based on metadata criteria. This utility provides functions for searching and filtering images based on metadata criteria.
|
|
It integrates with the MetadataService for consistent metadata handling.
|
|
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:
|
|
|
|
|
|
"""
|
|
|
|
import os
|
|
import glob
|
|
from typing import List, Dict, Any, Optional, Union, Set
|
|
import json
|
|
import re
|
|
from AAA_Metadata_System import MetadataService
|
|
from AAA_Metadata_System.eric_metadata.utils.workflow_parser import WorkflowParser
|
|
|
|
class MetadataQuery:
|
|
"""Utility for querying images based on metadata criteria"""
|
|
|
|
def __init__(self, debug: bool = False):
|
|
"""
|
|
Initialize the metadata query utility
|
|
|
|
Args:
|
|
debug: Whether to enable debug logging
|
|
"""
|
|
self.debug = debug
|
|
self.metadata_service = MetadataService(debug=debug)
|
|
self.workflow_parser = WorkflowParser(debug=debug)
|
|
|
|
def find_images(self, folder_path: str, criteria: Dict[str, Any],
|
|
limit: int = None, recursive: bool = False,
|
|
search_embedded_workflow: bool = True,
|
|
metadata_sources: List[str] = None) -> List[Dict[str, Any]]:
|
|
"""
|
|
Find images matching complex metadata criteria
|
|
|
|
Args:
|
|
folder_path: Path to image folder
|
|
criteria: Dict of metadata criteria
|
|
limit: Maximum number of results to return
|
|
recursive: Whether to search subfolders recursively
|
|
search_embedded_workflow: Whether to search PNG embedded workflows
|
|
metadata_sources: List of metadata sources to check ('embedded', 'xmp', 'txt', 'db')
|
|
If None, defaults to ['embedded', 'xmp']
|
|
|
|
Returns:
|
|
List of dicts with image path and relevant metadata
|
|
"""
|
|
# Set default metadata sources if not specified
|
|
if metadata_sources is None:
|
|
metadata_sources = ['embedded', 'xmp']
|
|
|
|
# Get all image files
|
|
image_files = []
|
|
for ext in ['*.jpg', '*.jpeg', '*.png', '*.webp', '*.tiff', '*.tif']:
|
|
if recursive:
|
|
pattern = os.path.join(folder_path, '**', ext)
|
|
image_files.extend(glob.glob(pattern, recursive=True))
|
|
else:
|
|
pattern = os.path.join(folder_path, ext)
|
|
image_files.extend(glob.glob(pattern))
|
|
|
|
# Log count of files found
|
|
if self.debug:
|
|
print(f"[MetadataQuery] Found {len(image_files)} image files to search")
|
|
|
|
results = []
|
|
|
|
for img_path in image_files:
|
|
# First read metadata using service (will try sources in order)
|
|
metadata = self.metadata_service.read_metadata(img_path, fallback=True)
|
|
|
|
# For PNGs, also check embedded workflow if needed and if not already extracted
|
|
if (search_embedded_workflow and
|
|
img_path.lower().endswith('.png') and
|
|
(not metadata or 'ai_info' not in metadata or 'workflow' not in metadata.get('ai_info', {}))):
|
|
|
|
# Extract workflow and convert to metadata format
|
|
workflow_data = self._extract_workflow_data(img_path)
|
|
if workflow_data:
|
|
# Merge workflow data with existing metadata
|
|
if not metadata:
|
|
metadata = {}
|
|
|
|
# Merge with existing metadata
|
|
metadata = self.metadata_service._merge_metadata(metadata, workflow_data)
|
|
|
|
# Check if image matches all criteria
|
|
if self._matches_criteria(metadata, criteria):
|
|
score = self._calculate_score(metadata, criteria)
|
|
results.append({
|
|
'path': img_path,
|
|
'score': score,
|
|
'metadata': self._extract_relevant_metadata(metadata, criteria)
|
|
})
|
|
|
|
# Sort by score (if sorting criteria provided)
|
|
if 'sort_by' in criteria:
|
|
results.sort(key=lambda x: x['score'], reverse=criteria.get('sort_order', 'desc') == 'desc')
|
|
|
|
# Apply limit if specified
|
|
if limit is not None and limit > 0:
|
|
results = results[:limit]
|
|
|
|
# Log results count
|
|
if self.debug:
|
|
print(f"[MetadataQuery] Found {len(results)} matching images")
|
|
|
|
return results
|
|
|
|
def _extract_workflow_data(self, img_path: str) -> Dict[str, Any]:
|
|
"""
|
|
Extract workflow data from PNG and convert to metadata format
|
|
|
|
Args:
|
|
img_path: Path to PNG file
|
|
|
|
Returns:
|
|
dict: Workflow data in metadata format
|
|
"""
|
|
try:
|
|
# Use the workflow parser to extract complete data
|
|
return self.workflow_parser.extract_and_convert_to_ai_metadata(img_path)
|
|
except Exception as e:
|
|
if self.debug:
|
|
print(f"[MetadataQuery] Error extracting workflow: {str(e)}")
|
|
return {}
|
|
|
|
def _matches_criteria(self, metadata: Dict[str, Any], criteria: Dict[str, Any]) -> bool:
|
|
"""
|
|
Check if metadata matches all criteria
|
|
|
|
Args:
|
|
metadata: Metadata dictionary
|
|
criteria: Criteria dictionary
|
|
|
|
Returns:
|
|
bool: True if metadata matches all criteria
|
|
"""
|
|
for field, condition in criteria.items():
|
|
# Skip special fields like sort_by
|
|
if field in ['sort_by', 'sort_order']:
|
|
continue
|
|
|
|
# Handle nested fields using dot notation
|
|
value = self._get_nested_value(metadata, field)
|
|
|
|
# Different condition types
|
|
if isinstance(condition, dict):
|
|
# Check all condition requirements
|
|
for op, check_value in condition.items():
|
|
if not self._evaluate_condition(value, op, check_value):
|
|
return False
|
|
else:
|
|
# Simple equality check
|
|
if value != condition:
|
|
return False
|
|
|
|
return True
|
|
|
|
def _evaluate_condition(self, value: Any, op: str, check_value: Any) -> bool:
|
|
"""
|
|
Evaluate a single condition
|
|
|
|
Args:
|
|
value: The value to check
|
|
op: Operation ('min', 'max', 'equals', etc.)
|
|
check_value: The value to check against
|
|
|
|
Returns:
|
|
bool: Whether the condition is met
|
|
"""
|
|
if op == 'min':
|
|
return value is not None and value >= check_value
|
|
elif op == 'max':
|
|
return value is not None and value <= check_value
|
|
elif op == 'equals':
|
|
return value == check_value
|
|
elif op == 'not_equals':
|
|
return value != check_value
|
|
elif op == 'contains':
|
|
return self._contains_value(value, check_value)
|
|
elif op == 'contains_all':
|
|
return self._contains_all_values(value, check_value)
|
|
elif op == 'contains_any':
|
|
return self._contains_any_values(value, check_value)
|
|
elif op == 'regex':
|
|
return self._matches_regex(value, check_value)
|
|
elif op == 'greater_than':
|
|
return value is not None and value > check_value
|
|
elif op == 'less_than':
|
|
return value is not None and value < check_value
|
|
else:
|
|
return False
|
|
|
|
def _get_nested_value(self, data: Dict[str, Any], field_path: str) -> Any:
|
|
"""
|
|
Get value from nested dict using dot notation path
|
|
|
|
Args:
|
|
data: Data dictionary
|
|
field_path: Field path with dot notation (e.g., 'ai_info.generation.model')
|
|
|
|
Returns:
|
|
The value at the specified path or None if not found
|
|
"""
|
|
parts = field_path.split('.')
|
|
current = data
|
|
|
|
for part in parts:
|
|
if not isinstance(current, dict) or part not in current:
|
|
return None
|
|
current = current[part]
|
|
|
|
return current
|
|
|
|
def _contains_value(self, container: Any, value: Any) -> bool:
|
|
"""
|
|
Check if container contains value (handles different container types)
|
|
|
|
Args:
|
|
container: Container to search in
|
|
value: Value to search for
|
|
|
|
Returns:
|
|
bool: True if value is found
|
|
"""
|
|
if container is None:
|
|
return False
|
|
|
|
if isinstance(container, str):
|
|
return value.lower() in container.lower() # Case-insensitive search for strings
|
|
|
|
if isinstance(container, (list, tuple, set)):
|
|
# For string values in lists, do case-insensitive comparison
|
|
if all(isinstance(item, str) for item in container):
|
|
return any(value.lower() == item.lower() for item in container)
|
|
return value in container
|
|
|
|
if isinstance(container, dict):
|
|
return value in container or value in container.values()
|
|
|
|
return False
|
|
|
|
def _contains_all_values(self, container: Any, values: List[Any]) -> bool:
|
|
"""
|
|
Check if container contains all values in the list
|
|
|
|
Args:
|
|
container: Container to search in
|
|
values: List of values to search for
|
|
|
|
Returns:
|
|
bool: True if all values are found
|
|
"""
|
|
return all(self._contains_value(container, value) for value in values)
|
|
|
|
def _contains_any_values(self, container: Any, values: List[Any]) -> bool:
|
|
"""
|
|
Check if container contains any value in the list
|
|
|
|
Args:
|
|
container: Container to search in
|
|
values: List of values to search for
|
|
|
|
Returns:
|
|
bool: True if any value is found
|
|
"""
|
|
return any(self._contains_value(container, value) for value in values)
|
|
|
|
def _matches_regex(self, value: Any, pattern: str) -> bool:
|
|
"""
|
|
Check if value matches regex pattern
|
|
|
|
Args:
|
|
value: Value to check
|
|
pattern: Regex pattern
|
|
|
|
Returns:
|
|
bool: True if matches
|
|
"""
|
|
if not isinstance(value, str):
|
|
return False
|
|
|
|
try:
|
|
return bool(re.search(pattern, value))
|
|
except:
|
|
return False
|
|
|
|
def _calculate_score(self, metadata: Dict[str, Any], criteria: Dict[str, Any]) -> float:
|
|
"""
|
|
Calculate score for sorting based on criteria
|
|
|
|
Args:
|
|
metadata: Metadata dictionary
|
|
criteria: Criteria dictionary
|
|
|
|
Returns:
|
|
float: Score for sorting
|
|
"""
|
|
# Get sort field
|
|
sort_field = criteria.get('sort_by')
|
|
if not sort_field:
|
|
return 0.0
|
|
|
|
# Get value from metadata
|
|
value = self._get_nested_value(metadata, sort_field)
|
|
|
|
# Handle different value types
|
|
if isinstance(value, (int, float)):
|
|
return float(value)
|
|
elif isinstance(value, str):
|
|
return len(value) # String length as score
|
|
elif isinstance(value, (list, tuple, set)):
|
|
return len(value) # Container length as score
|
|
elif isinstance(value, dict):
|
|
return len(value) # Dict size as score
|
|
|
|
return 0.0
|
|
|
|
def _extract_relevant_metadata(self, metadata: Dict[str, Any], criteria: Dict[str, Any]) -> Dict[str, Any]:
|
|
"""
|
|
Extract only metadata fields relevant to the criteria
|
|
|
|
Args:
|
|
metadata: Complete metadata
|
|
criteria: Criteria used for filtering
|
|
|
|
Returns:
|
|
dict: Subset of metadata with relevant fields
|
|
"""
|
|
result = {}
|
|
|
|
# Get fields mentioned in criteria
|
|
fields = list(criteria.keys())
|
|
|
|
# Add sort field if present
|
|
if 'sort_by' in criteria:
|
|
fields.append(criteria['sort_by'])
|
|
|
|
# Extract each field
|
|
for field in fields:
|
|
# Skip special fields
|
|
if field in ['sort_by', 'sort_order']:
|
|
continue
|
|
|
|
value = self._get_nested_value(metadata, field)
|
|
if value is not None:
|
|
# Build nested structure
|
|
self._set_nested_value(result, field, value)
|
|
|
|
return result
|
|
|
|
def _set_nested_value(self, data: Dict[str, Any], field_path: str, value: Any) -> None:
|
|
"""
|
|
Set value in nested dict using dot notation path
|
|
|
|
Args:
|
|
data: Data dictionary to update
|
|
field_path: Field path with dot notation
|
|
value: Value to set
|
|
"""
|
|
parts = field_path.split('.')
|
|
current = data
|
|
|
|
# Create nested dictionaries
|
|
for i, part in enumerate(parts[:-1]):
|
|
if part not in current:
|
|
current[part] = {}
|
|
current = current[part]
|
|
|
|
# Set the final value
|
|
current[parts[-1]] = value
|
|
|
|
def get_unique_values(self, folder_path: str, field_path: str,
|
|
recursive: bool = False) -> Set[Any]:
|
|
"""
|
|
Get set of unique values for a specific metadata field across images
|
|
|
|
Args:
|
|
folder_path: Path to image folder
|
|
field_path: Field path with dot notation
|
|
recursive: Whether to search subfolders recursively
|
|
|
|
Returns:
|
|
set: Set of unique values
|
|
"""
|
|
# Find all images without any filtering criteria
|
|
results = self.find_images(folder_path, {'sort_by': field_path}, recursive=recursive)
|
|
|
|
# Extract unique values for the specified field
|
|
unique_values = set()
|
|
for result in results:
|
|
value = self._get_nested_value(result['metadata'], field_path)
|
|
if value is not None:
|
|
if isinstance(value, (list, tuple, set)):
|
|
for item in value:
|
|
unique_values.add(item)
|
|
else:
|
|
unique_values.add(value)
|
|
|
|
return unique_values
|
|
|
|
def get_value_counts(self, folder_path: str, field_path: str,
|
|
recursive: bool = False) -> Dict[Any, int]:
|
|
"""
|
|
Get count of occurrences for each unique value of a field
|
|
|
|
Args:
|
|
folder_path: Path to image folder
|
|
field_path: Field path with dot notation
|
|
recursive: Whether to search subfolders recursively
|
|
|
|
Returns:
|
|
dict: Mapping of values to counts
|
|
"""
|
|
# Find all images without any filtering criteria
|
|
results = self.find_images(folder_path, {'sort_by': field_path}, recursive=recursive)
|
|
|
|
# Count occurrences of each value
|
|
value_counts = {}
|
|
for result in results:
|
|
value = self._get_nested_value(result['metadata'], field_path)
|
|
if value is not None:
|
|
if isinstance(value, (list, tuple, set)):
|
|
for item in value:
|
|
value_counts[item] = value_counts.get(item, 0) + 1
|
|
else:
|
|
value_counts[value] = value_counts.get(value, 0) + 1
|
|
|
|
return value_counts
|
|
|
|
def cleanup(self):
|
|
"""Clean up resources"""
|
|
if hasattr(self, 'metadata_service'):
|
|
self.metadata_service.cleanup()
|
|
|
|
def __enter__(self):
|
|
"""Context manager entry"""
|
|
return self
|
|
|
|
def __exit__(self, exc_type, exc_val, exc_tb):
|
|
"""Context manager exit"""
|
|
self.cleanup()
|