Major Features: - Backward compatibility: VALIDATE_INPUTS handles old boolean True/False values for enable_runtime_hooks parameter - Phase 3: XMP region metadata with MWG-RS compliance (faces, areas, coordinates, extensions) - Phase 4.4: Per-node UI toggle for runtime hooks (dropdown: Auto/Enabled/Disabled) - Runtime hooks enhancements: performance tracking, disagreement logging, configurable limits New Files: - config.json: Configuration file for runtime hooks and metadata settings - eric_metadata/hooks/runtime_capture.py: Runtime hook infrastructure - eric_metadata/utils/config.py: Configuration loader with priority system - eric_metadata/utils/version.py: Version helper for generated_by labels - eric_metadata/utils/a1111.py: Automatic1111 parameter string generation - eric_metadata/utils/jpeg_fallback.py: 4-stage JPEG metadata fallback - eric_metadata/workflow/: Workflow parsing and generation helpers - tests/test_backward_compatibility.py: 7 tests for parameter migration - tests/test_xmp_regions.py: 6 tests for XMP region round-trip - tests/test_per_node_runtime_hooks.py: 9 tests for per-node toggle Documentation: - CONFIG_GUIDE.md: Configuration options and usage - RUNTIME_HOOKS_QUICK_REFERENCE.md: Quick reference for runtime hooks - RUNTIME_HOOKS_ENHANCEMENTS_SUMMARY.md: Detailed enhancement documentation - SAFE_RUNTIME_HOOKS_RESEARCH.md: Safety analysis and validation - edit_log.md: Complete change history Test Results: All 143 tests passing - 7 backward compatibility tests - 6 XMP region tests - 9 per-node toggle tests - 17 runtime hooks enhancement tests - 104 existing tests Breaking Changes: None - fully backward compatible
250 lines
8.0 KiB
Python
250 lines
8.0 KiB
Python
"""
|
|
Hash Utility Functions - Perceptual image hashing utilities for duplicate detection
|
|
|
|
This module provides functions for calculating perceptual image hashes
|
|
and storing them in metadata. It also offers helpers for hashing arbitrary
|
|
files with optional sidecar caching to avoid re-reading large model files.
|
|
|
|
Author: Eric Hiss (GitHub: EricRollei)
|
|
Contact: [eric@historic.camera, eric@rollei.us]
|
|
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.
|
|
"""
|
|
|
|
import hashlib
|
|
import logging
|
|
import os
|
|
import string
|
|
from datetime import datetime
|
|
from pathlib import Path
|
|
from typing import Optional, Union
|
|
|
|
from PIL import Image
|
|
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# Check for imagehash library
|
|
try:
|
|
import imagehash
|
|
IMAGEHASH_AVAILABLE = True
|
|
except ImportError:
|
|
print("Warning: imagehash library not installed. To enable image hash functionality:")
|
|
print("pip install imagehash")
|
|
IMAGEHASH_AVAILABLE = False
|
|
|
|
def calculate_image_hash(image, hash_algorithm="phash"):
|
|
"""
|
|
Calculate perceptual hash for an image
|
|
|
|
Args:
|
|
image: PIL Image object
|
|
hash_algorithm: Hash algorithm to use ('phash', 'dhash', 'average_hash', 'whash-haar')
|
|
|
|
Returns:
|
|
str: String representation of the hash, or None if error
|
|
"""
|
|
if not IMAGEHASH_AVAILABLE:
|
|
return None
|
|
|
|
try:
|
|
# Calculate hash based on selected algorithm
|
|
hash_value = None
|
|
if hash_algorithm == "phash":
|
|
hash_value = imagehash.phash(image)
|
|
elif hash_algorithm == "dhash":
|
|
hash_value = imagehash.dhash(image)
|
|
elif hash_algorithm == "whash-haar":
|
|
hash_value = imagehash.whash(image)
|
|
else: # default to average_hash
|
|
hash_value = imagehash.average_hash(image)
|
|
|
|
# Return string representation
|
|
return str(hash_value)
|
|
|
|
except Exception as e:
|
|
print(f"Error calculating image hash: {e}")
|
|
return None
|
|
|
|
def calculate_hash_from_file(file_path, hash_algorithm="phash"):
|
|
"""
|
|
Calculate perceptual hash for an image file
|
|
|
|
Args:
|
|
file_path: Path to image file
|
|
hash_algorithm: Hash algorithm to use
|
|
|
|
Returns:
|
|
str: String representation of the hash, or None if error
|
|
"""
|
|
if not IMAGEHASH_AVAILABLE:
|
|
return None
|
|
|
|
try:
|
|
# Open image
|
|
img = Image.open(file_path)
|
|
# Calculate hash
|
|
return calculate_image_hash(img, hash_algorithm)
|
|
except Exception as e:
|
|
print(f"Error calculating hash from file {file_path}: {e}")
|
|
return None
|
|
|
|
def save_hash_to_metadata(file_path, hash_value, hash_algorithm, metadata_service):
|
|
"""
|
|
Save hash value to image metadata
|
|
|
|
Args:
|
|
file_path: Path to image file
|
|
hash_value: String representation of hash
|
|
hash_algorithm: Hash algorithm used
|
|
metadata_service: Initialized metadata service
|
|
|
|
Returns:
|
|
bool: True if successful, False otherwise
|
|
"""
|
|
if not metadata_service:
|
|
return False
|
|
|
|
try:
|
|
# Create hash metadata
|
|
hash_metadata = {
|
|
'eiqa': {
|
|
'technical': {
|
|
'hashes': {
|
|
hash_algorithm: hash_value,
|
|
'hash_time': datetime.now().isoformat()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
# Set resource identifier
|
|
filename = os.path.basename(file_path)
|
|
resource_uri = f"file:///{filename}"
|
|
metadata_service.set_resource_identifier(resource_uri)
|
|
|
|
# Write hash metadata
|
|
metadata_service.write_metadata(
|
|
file_path,
|
|
hash_metadata,
|
|
targets=['embedded', 'xmp', 'database']
|
|
)
|
|
|
|
return True
|
|
|
|
except Exception as e:
|
|
print(f"Error saving hash to metadata: {e}")
|
|
return False
|
|
|
|
def calculate_and_save_hash(image_or_path, file_path, metadata_service, hash_algorithm="phash"):
|
|
"""
|
|
Calculate image hash and save to metadata
|
|
|
|
Args:
|
|
image_or_path: PIL Image or path to image file
|
|
file_path: Path where image is/will be saved
|
|
metadata_service: Initialized metadata service
|
|
hash_algorithm: Hash algorithm to use
|
|
|
|
Returns:
|
|
str: String representation of the hash or None if error
|
|
"""
|
|
try:
|
|
# Calculate hash
|
|
hash_value = None
|
|
|
|
if isinstance(image_or_path, str):
|
|
# It's a file path
|
|
hash_value = calculate_hash_from_file(image_or_path, hash_algorithm)
|
|
else:
|
|
# It's a PIL Image
|
|
hash_value = calculate_image_hash(image_or_path, hash_algorithm)
|
|
|
|
if hash_value:
|
|
# Save to metadata
|
|
save_hash_to_metadata(file_path, hash_value, hash_algorithm, metadata_service)
|
|
|
|
return hash_value
|
|
|
|
except Exception as e:
|
|
print(f"Error in calculate_and_save_hash: {e}")
|
|
return None
|
|
|
|
|
|
def _is_valid_sha256_digest(value: str) -> bool:
|
|
"""Quick validation for cached SHA256 strings."""
|
|
if len(value) != 64:
|
|
return False
|
|
return all(ch in string.hexdigits for ch in value)
|
|
|
|
|
|
def hash_file_sha256(file_path: Union[str, Path], use_cache: bool = True) -> Optional[str]:
|
|
"""Compute a SHA256 hash for ``file_path`` with optional sidecar caching.
|
|
|
|
The cache keeps a ``.sha256`` file next to the original resource and is
|
|
considered valid when its mtime is not older than the source file. Invalid
|
|
or outdated caches are ignored and overwritten on successful recomputation.
|
|
|
|
Args:
|
|
file_path: Path to the file that should be hashed.
|
|
use_cache: Whether to consult and persist the optional ``.sha256``
|
|
sidecar. Set to ``False`` to force a live recompute without writing
|
|
cache data.
|
|
|
|
Returns:
|
|
The 64-character hexadecimal digest, or ``None`` when the file does not
|
|
exist or hashing fails for any reason.
|
|
"""
|
|
|
|
path = Path(file_path)
|
|
|
|
if not path.exists() or not path.is_file():
|
|
logger.warning("hash_file_sha256: Path does not exist or is not a file: %s", path)
|
|
return None
|
|
|
|
cache_path = path.with_suffix(path.suffix + ".sha256")
|
|
|
|
if use_cache and cache_path.exists():
|
|
try:
|
|
cache_mtime = cache_path.stat().st_mtime
|
|
source_mtime = path.stat().st_mtime
|
|
|
|
if cache_mtime >= source_mtime:
|
|
cached_value = cache_path.read_text(encoding="utf-8").strip()
|
|
if _is_valid_sha256_digest(cached_value):
|
|
return cached_value.lower()
|
|
|
|
logger.debug("hash_file_sha256: Invalid cache contents in %s", cache_path)
|
|
except OSError as exc:
|
|
logger.debug("hash_file_sha256: Failed to read cache %s (%s)", cache_path, exc)
|
|
|
|
try:
|
|
sha256_hash = hashlib.sha256()
|
|
with path.open("rb") as handle:
|
|
for chunk in iter(lambda: handle.read(8192), b""):
|
|
sha256_hash.update(chunk)
|
|
|
|
digest = sha256_hash.hexdigest()
|
|
|
|
if use_cache:
|
|
try:
|
|
cache_path.write_text(digest + "\n", encoding="utf-8")
|
|
except OSError as exc:
|
|
logger.debug("hash_file_sha256: Unable to write cache %s (%s)", cache_path, exc)
|
|
|
|
return digest
|
|
except OSError as exc:
|
|
logger.error("hash_file_sha256: Failed hashing %s (%s)", path, exc)
|
|
return None |