Skip to content

Instantly share code, notes, and snippets.

@EntilZha
Created November 23, 2025 00:59
Show Gist options
  • Select an option

  • Save EntilZha/b4db8fe37df365be9a806933779f2c67 to your computer and use it in GitHub Desktop.

Select an option

Save EntilZha/b4db8fe37df365be9a806933779f2c67 to your computer and use it in GitHub Desktop.
Streamlit Photo Metadata Editor - Web-based tool for editing photo gallery metadata.yaml files

Photo Metadata Editor

A Streamlit-based web interface for editing photo metadata.yaml files.

Installation

Install the required dependencies:

pip install -r requirements-metadata-editor.txt

Usage

Run the metadata editor with:

streamlit run metadata-editor.py -- <path/to/metadata.yaml> <path/to/photo/directory>

Example

# Edit metadata for a gallery
streamlit run metadata-editor.py -- src/assets/photos/bcs-birds/metadata.yaml src/assets/photos/bcs-birds

# Create new metadata for a directory
streamlit run metadata-editor.py -- src/assets/photos/new-gallery/metadata.yaml src/assets/photos/new-gallery

Features

  • Gallery Name Editing: Edit the gallery name at the top of the page
  • Photo Metadata Editing: For each photo, edit:
    • Title
    • Description
    • Location
    • Date
    • Alt text
    • Tags (comma-separated)
    • Visible (checkbox)
  • Photo Preview: View each photo alongside its metadata
  • EXIF Data Display: View read-only EXIF data if available
  • Automatic Backup: Creates timestamped backup before saving changes
  • Auto-discovery: Automatically finds all photos in the directory
  • New Photo Support: Automatically adds new photos found in directory

How It Works

  1. The tool scans the photo directory for image files (jpg, jpeg, png, webp)
  2. Loads existing metadata.yaml or creates a new one if empty/missing
  3. Displays a web interface with all photos and their metadata
  4. When you make changes and click "Save All Changes":
    • Creates a backup of the existing metadata.yaml (with timestamp)
    • Saves your changes to metadata.yaml
  5. Changes are preserved across sessions

Notes

  • The tool preserves EXIF data but doesn't allow editing it (EXIF should be updated via the add-exif-metadata.js script)
  • Backups are created with format: metadata.yaml.backup.YYYYMMDD_HHMMSS
  • The output format matches the structure used by add-exif-metadata.js
  • Photo directory is specified relative to the metadata.yaml location (usually '.')
#!/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()
streamlit>=1.28.0
PyYAML>=6.0
Pillow>=10.0.0
exifread>=3.0.0
streamlit-tags>=1.2.8
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment