|
#!/usr/bin/env python3 |
|
""" |
|
Photo Metadata Editor |
|
|
|
A Streamlit-based web interface for editing photo metadata.yaml files. |
|
|
|
Usage: |
|
python metadata-editor.py <metadata.yaml> <photo_directory> |
|
|
|
Features: |
|
- Edit gallery name |
|
- Edit metadata for each photo (tags, visible, title, description, location, date, alt) |
|
- View photos alongside their metadata |
|
- Automatic backup before saving changes |
|
""" |
|
|
|
import sys |
|
import argparse |
|
from pathlib import Path |
|
import yaml |
|
from datetime import datetime |
|
import shutil |
|
import streamlit as st |
|
from PIL import Image |
|
import exifread |
|
from streamlit_tags import st_tags |
|
|
|
def create_default_metadata(gallery_name, photo_dir_path): |
|
"""Create default metadata structure""" |
|
return { |
|
'name': gallery_name, |
|
'photo_dir': '.', |
|
'external_photo_dir': None, |
|
'photos': [] |
|
} |
|
|
|
def load_metadata(metadata_path): |
|
"""Load metadata from YAML file""" |
|
if not metadata_path.exists(): |
|
return None |
|
|
|
try: |
|
with open(metadata_path, 'r') as f: |
|
content = f.read() |
|
if not content.strip(): |
|
return None |
|
return yaml.safe_load(content) |
|
except Exception as e: |
|
st.error(f"Error loading metadata: {e}") |
|
return None |
|
|
|
def save_metadata(metadata_path, metadata, photo_files): |
|
"""Save metadata to YAML file with backup""" |
|
# Update metadata from widget values in session state |
|
for idx, photo_path in enumerate(photo_files): |
|
filename = photo_path.name |
|
# Find the photo in metadata |
|
photo_meta = None |
|
for photo in metadata['photos']: |
|
if photo['filename'] == filename: |
|
photo_meta = photo |
|
break |
|
|
|
if photo_meta: |
|
# Read values from session state widgets |
|
photo_meta['title'] = st.session_state.get(f'title_{idx}', '') |
|
photo_meta['description'] = st.session_state.get(f'description_{idx}', '') |
|
photo_meta['location'] = st.session_state.get(f'location_{idx}', '') |
|
photo_meta['date'] = st.session_state.get(f'date_{idx}', '') |
|
photo_meta['alt'] = st.session_state.get(f'alt_{idx}', '') |
|
photo_meta['visible'] = st.session_state.get(f'visible_{idx}', True) |
|
|
|
# Get tags (already a list from st_tags) |
|
tags = st.session_state.get(f'tags_{idx}', []) |
|
photo_meta['tags'] = tags if isinstance(tags, list) else [] |
|
|
|
# Update gallery name |
|
metadata['name'] = st.session_state.get('gallery_name', metadata.get('name', '')) |
|
|
|
# Save metadata (no backup - created once at start of session) |
|
with open(metadata_path, 'w') as f: |
|
yaml.dump(metadata, f, default_flow_style=False, sort_keys=False, allow_unicode=True) |
|
|
|
st.success(f"Metadata saved successfully!") |
|
|
|
def get_photo_files(photo_dir): |
|
"""Get all photo files from directory""" |
|
photo_extensions = {'.jpg', '.jpeg', '.png', '.webp'} |
|
photos = [] |
|
for ext in photo_extensions: |
|
photos.extend(photo_dir.glob(f'*{ext}')) |
|
photos.extend(photo_dir.glob(f'*{ext.upper()}')) |
|
return sorted(photos) |
|
|
|
def collect_tags_from_directory(base_dir): |
|
"""Collect all unique tags from metadata.yaml files in directory""" |
|
tags = set() |
|
base_path = Path(base_dir) |
|
|
|
if not base_path.exists(): |
|
return [] |
|
|
|
# Find all metadata.yaml files recursively |
|
for metadata_file in base_path.rglob('metadata.yaml'): |
|
try: |
|
with open(metadata_file, 'r') as f: |
|
metadata = yaml.safe_load(f) |
|
if metadata and 'photos' in metadata: |
|
for photo in metadata['photos']: |
|
if 'tags' in photo and photo['tags']: |
|
tags.update(photo['tags']) |
|
except Exception as e: |
|
st.warning(f"Error reading {metadata_file}: {e}") |
|
continue |
|
|
|
return sorted(list(tags)) |
|
|
|
def ensure_photo_in_metadata(metadata, filename): |
|
"""Ensure a photo entry exists in metadata""" |
|
for photo in metadata['photos']: |
|
if photo['filename'] == filename: |
|
return photo |
|
|
|
# Create new photo entry |
|
new_photo = { |
|
'filename': filename, |
|
'title': '', |
|
'description': '', |
|
'location': '', |
|
'date': '', |
|
'tags': [], |
|
'alt': '', |
|
'visible': True, |
|
'exif': {} |
|
} |
|
metadata['photos'].append(new_photo) |
|
return new_photo |
|
|
|
def extract_exif_data(photo_path): |
|
"""Extract EXIF data from a photo file using exifread""" |
|
try: |
|
with open(photo_path, 'rb') as f: |
|
tags = exifread.process_file(f, details=False) |
|
|
|
if not tags: |
|
return None |
|
|
|
exif = {} |
|
|
|
# F-stop (aperture) |
|
if 'EXIF FNumber' in tags: |
|
try: |
|
f_num = tags['EXIF FNumber'].values[0] |
|
f_val = float(f_num.num) / float(f_num.den) if f_num.den != 0 else float(f_num.num) |
|
exif['fStop'] = f"f/{f_val:.1f}" |
|
except: |
|
pass |
|
|
|
# Exposure time (shutter speed) |
|
if 'EXIF ExposureTime' in tags: |
|
try: |
|
exp_time = tags['EXIF ExposureTime'].values[0] |
|
exp_val = float(exp_time.num) / float(exp_time.den) if exp_time.den != 0 else float(exp_time.num) |
|
|
|
if exp_val < 1: |
|
# Format as fraction |
|
exif['exposureTime'] = f"1/{int(1 / exp_val)}" |
|
else: |
|
# Format as decimal for long exposures |
|
exif['exposureTime'] = f"{exp_val:.1f}s" |
|
except: |
|
pass |
|
|
|
# ISO |
|
if 'EXIF ISOSpeedRatings' in tags: |
|
try: |
|
iso = tags['EXIF ISOSpeedRatings'].values[0] |
|
exif['iso'] = int(iso) |
|
except: |
|
pass |
|
|
|
# Focal length |
|
if 'EXIF FocalLength' in tags: |
|
try: |
|
focal = tags['EXIF FocalLength'].values[0] |
|
focal_val = float(focal.num) / float(focal.den) if focal.den != 0 else float(focal.num) |
|
exif['focalLength'] = f"{int(round(focal_val))}mm" |
|
except: |
|
pass |
|
|
|
# Lens model |
|
if 'EXIF LensModel' in tags: |
|
try: |
|
exif['lens'] = str(tags['EXIF LensModel'].values) |
|
except: |
|
pass |
|
|
|
# Camera (Make + Model) |
|
make = '' |
|
model = '' |
|
if 'Image Make' in tags: |
|
try: |
|
make = str(tags['Image Make'].values) |
|
except: |
|
pass |
|
if 'Image Model' in tags: |
|
try: |
|
model = str(tags['Image Model'].values) |
|
except: |
|
pass |
|
|
|
if make and model: |
|
exif['camera'] = f"{make} {model}" |
|
|
|
# Date taken |
|
date_taken = None |
|
if 'EXIF DateTimeOriginal' in tags: |
|
date_taken = str(tags['EXIF DateTimeOriginal'].values) |
|
elif 'Image DateTime' in tags: |
|
date_taken = str(tags['Image DateTime'].values) |
|
|
|
if date_taken: |
|
try: |
|
# Parse format: "YYYY:MM:DD HH:MM:SS" |
|
date_str = date_taken.split(' ')[0] |
|
date_parts = date_str.split(':') |
|
if len(date_parts) == 3: |
|
exif['dateTaken'] = f"{date_parts[0]}-{date_parts[1]}-{date_parts[2]}" |
|
except: |
|
pass |
|
|
|
return exif if exif else None |
|
|
|
except Exception as e: |
|
st.error(f"Error extracting EXIF from {photo_path.name}: {e}") |
|
return None |
|
|
|
def main(): |
|
st.set_page_config( |
|
page_title="Photo Metadata Editor", |
|
page_icon="πΈ", |
|
layout="wide" |
|
) |
|
|
|
# Get command line arguments from session state |
|
if 'metadata_path' not in st.session_state: |
|
if len(sys.argv) < 3: |
|
st.error("Usage: streamlit run metadata-editor.py -- <metadata.yaml> <photo_directory> [tag_source_directory]") |
|
st.stop() |
|
|
|
st.session_state.metadata_path = Path(sys.argv[1]) |
|
st.session_state.photo_dir = Path(sys.argv[2]) |
|
|
|
# Optional: directory to scan for existing tags |
|
if len(sys.argv) >= 4: |
|
st.session_state.tag_source_dir = Path(sys.argv[3]) |
|
else: |
|
st.session_state.tag_source_dir = None |
|
|
|
metadata_path = st.session_state.metadata_path |
|
photo_dir = st.session_state.photo_dir |
|
tag_source_dir = st.session_state.get('tag_source_dir') |
|
|
|
# Validate paths |
|
if not photo_dir.exists(): |
|
st.error(f"Photo directory not found: {photo_dir}") |
|
st.stop() |
|
|
|
# Collect tag suggestions |
|
if 'tag_suggestions' not in st.session_state: |
|
if tag_source_dir and tag_source_dir.exists(): |
|
st.session_state.tag_suggestions = collect_tags_from_directory(tag_source_dir) |
|
if st.session_state.tag_suggestions: |
|
st.info(f"Loaded {len(st.session_state.tag_suggestions)} unique tags from {tag_source_dir}") |
|
else: |
|
st.session_state.tag_suggestions = ['bird', 'landscape', 'macro', 'wildlife', 'nature', 'portrait'] |
|
else: |
|
st.session_state.tag_suggestions = ['bird', 'landscape', 'macro', 'wildlife', 'nature', 'portrait'] |
|
|
|
tag_suggestions = st.session_state.tag_suggestions |
|
|
|
# Load or create metadata (only once per session) |
|
if 'metadata' not in st.session_state: |
|
metadata = load_metadata(metadata_path) |
|
|
|
# Create backup on initial load if file exists |
|
if metadata_path.exists() and metadata is not None: |
|
backup_path = metadata_path.with_suffix(f'.yaml.backup.{datetime.now().strftime("%Y%m%d_%H%M%S")}') |
|
shutil.copy2(metadata_path, backup_path) |
|
st.success(f"Initial backup created: {backup_path.name}") |
|
|
|
if metadata is None: |
|
# Create default metadata |
|
gallery_name = photo_dir.name.replace('-', ' ').title() |
|
metadata = create_default_metadata(gallery_name, photo_dir) |
|
st.info(f"Created new metadata for gallery: {gallery_name}") |
|
|
|
# Ensure metadata structure is valid |
|
if 'photos' not in metadata: |
|
metadata['photos'] = [] |
|
if 'photo_dir' not in metadata: |
|
metadata['photo_dir'] = '.' |
|
if 'external_photo_dir' not in metadata: |
|
metadata['external_photo_dir'] = None |
|
|
|
st.session_state.metadata = metadata |
|
|
|
metadata = st.session_state.metadata |
|
|
|
# Title and gallery name editor |
|
st.title("πΈ Photo Metadata Editor") |
|
|
|
with st.container(): |
|
col1, col2, col3 = st.columns([3, 1, 1]) |
|
with col1: |
|
st.text_input( |
|
"Gallery Name", |
|
value=metadata.get('name', ''), |
|
key='gallery_name', |
|
on_change=lambda: None |
|
) |
|
with col2: |
|
if st.button("π Extract EXIF", type="secondary", width='stretch'): |
|
# Extract EXIF for all photos |
|
photo_files = get_photo_files(photo_dir) |
|
success_count = 0 |
|
for photo_path in photo_files: |
|
filename = photo_path.name |
|
exif = extract_exif_data(photo_path) |
|
if exif: |
|
# Find photo in metadata and update EXIF |
|
for photo in metadata['photos']: |
|
if photo['filename'] == filename: |
|
photo['exif'] = exif |
|
success_count += 1 |
|
break |
|
st.success(f"Extracted EXIF data from {success_count}/{len(photo_files)} photos") |
|
st.rerun() |
|
with col3: |
|
if st.button("πΎ Save All", type="primary", width='stretch'): |
|
save_metadata(metadata_path, st.session_state.metadata, st.session_state.get('photo_files', [])) |
|
st.rerun() |
|
|
|
st.divider() |
|
|
|
# Get all photos from directory |
|
photo_files = get_photo_files(photo_dir) |
|
|
|
if not photo_files: |
|
st.warning("No photos found in directory") |
|
st.stop() |
|
|
|
st.info(f"Found {len(photo_files)} photos in {photo_dir}") |
|
|
|
# Store photo_files in session state for save function |
|
st.session_state.photo_files = photo_files |
|
|
|
# Photo editor section |
|
for idx, photo_path in enumerate(photo_files): |
|
filename = photo_path.name |
|
|
|
# Ensure photo exists in metadata |
|
photo_metadata = ensure_photo_in_metadata(metadata, filename) |
|
|
|
st.subheader(f"π· {filename}") |
|
col1, col2 = st.columns([1, 2]) |
|
|
|
with col1: |
|
# Display photo |
|
try: |
|
img = Image.open(photo_path) |
|
# Resize for display |
|
img.thumbnail((400, 400)) |
|
st.image(img, width='stretch') |
|
except Exception as e: |
|
st.error(f"Error loading image: {e}") |
|
|
|
with col2: |
|
# Metadata fields - values are stored in session state by their keys |
|
# on_change=lambda: None triggers rerun on defocus (auto-save) |
|
st.text_input( |
|
"Title", |
|
value=photo_metadata.get('title', ''), |
|
key=f'title_{idx}', |
|
on_change=lambda: None |
|
) |
|
|
|
st.text_area( |
|
"Description", |
|
value=photo_metadata.get('description', ''), |
|
key=f'description_{idx}', |
|
height=100, |
|
on_change=lambda: None |
|
) |
|
|
|
col_a, col_b = st.columns(2) |
|
with col_a: |
|
st.text_input( |
|
"Location", |
|
value=photo_metadata.get('location', ''), |
|
key=f'location_{idx}', |
|
on_change=lambda: None |
|
) |
|
with col_b: |
|
st.text_input( |
|
"Date", |
|
value=photo_metadata.get('date', ''), |
|
key=f'date_{idx}', |
|
placeholder="YYYY-MM-DD", |
|
on_change=lambda: None |
|
) |
|
|
|
st.text_input( |
|
"Alt Text", |
|
value=photo_metadata.get('alt', ''), |
|
key=f'alt_{idx}', |
|
on_change=lambda: None |
|
) |
|
|
|
# Tags input using streamlit-tags |
|
st_tags( |
|
label='Tags', |
|
text='Press enter to add tag', |
|
value=photo_metadata.get('tags', []), |
|
suggestions=tag_suggestions, |
|
key=f'tags_{idx}' |
|
) |
|
|
|
st.checkbox( |
|
"Visible", |
|
value=photo_metadata.get('visible', True), |
|
key=f'visible_{idx}' |
|
) |
|
|
|
# Display EXIF data if available |
|
if 'exif' in photo_metadata and photo_metadata['exif']: |
|
st.markdown("**π EXIF Data**") |
|
exif = photo_metadata['exif'] |
|
exif_cols = st.columns(2) |
|
with exif_cols[0]: |
|
st.text(f"Camera: {exif.get('camera', 'N/A')}") |
|
st.text(f"Lens: {exif.get('lens', 'N/A')}") |
|
st.text(f"ISO: {exif.get('iso', 'N/A')}") |
|
with exif_cols[1]: |
|
st.text(f"Aperture: {exif.get('fStop', 'N/A')}") |
|
st.text(f"Shutter: {exif.get('exposureTime', 'N/A')}") |
|
st.text(f"Focal Length: {exif.get('focalLength', 'N/A')}") |
|
st.text(f"Date Taken: {exif.get('dateTaken', 'N/A')}") |
|
|
|
st.divider() |
|
|
|
if __name__ == "__main__": |
|
main() |