MemorySharing/ImageEditing/mfr.py

447 lines
15 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
"""
mfr.py MemorySharing Multi-File Renamer (Tkinter GUI)
This tool helps rename related media files (RAW, JPEG, thumbnails, websize
variants, WAV, etc.) in groups.
Grouping logic:
- Files in the selected directory are grouped by a common basename prefix.
- Variant suffixes like "_tn", "_ws", etc. are stripped to find the
"group key". All files with stems sharing that key are in one group.
Renaming logic:
- For each group, compute the earliest "logical timestamp" among its
members:
EXIF DateTimeOriginal (if present) OR filesystem mtime.
- Build a new base name:
[optional date]_[camera_tag]_[extra_text]_[group_index]
where:
- date is formatted with Python's datetime.strftime()
- camera_tag is chosen from a dropdown (or custom)
- extra_text is the user-supplied string, spaces -> '-'
- group_index is a 4-digit sequence (0001, 0002, ...)
- Each file in the group gets renamed to:
<new_base><original_variant_suffix><original_extension>
Example:
Original files:
IMG_1234.NEF
IMG_1234.JPG
IMG_1234_tn.jpg
If group index is 1, camera tag "d600", date "2025_0401", text "family",
they might be renamed to:
2025_0401_d600_family_0001.NEF
2025_0401_d600_family_0001.JPG
2025_0401_d600_family_0001_tn.jpg
"""
import os
from pathlib import Path
from datetime import datetime
import tkinter as tk
from tkinter import filedialog, messagebox, ttk
from PIL import Image
from PIL.ExifTags import TAGS
# ------------------------- Group / timestamp logic --------------------------
DEFAULT_VARIANT_MARKERS = ["_tn", "_ws"]
def get_exif_datetime_original(path: Path):
"""Return EXIF DateTimeOriginal as datetime, or None."""
try:
with Image.open(path) as img:
exif = img._getexif()
if not exif:
return None
exif_data = {TAGS.get(tag_id, tag_id): value for tag_id, value in exif.items()}
dto = exif_data.get("DateTimeOriginal")
if not dto:
return None
# Typical EXIF datetime format: "YYYY:MM:DD HH:MM:SS"
try:
return datetime.strptime(dto, "%Y:%m:%d %H:%M:%S")
except (ValueError, TypeError):
return None
except Exception:
return None
def get_logical_timestamp(path: Path) -> float:
"""Best timestamp: EXIF DateTimeOriginal if available, else mtime."""
exif_dt = get_exif_datetime_original(path)
if exif_dt is not None:
return exif_dt.timestamp()
return os.path.getmtime(path)
def derive_group_key(stem: str, variant_markers):
"""
Derive a "group key" from a stem by stripping known variant markers.
We look for the earliest occurrence of any marker like "_tn", "_ws",
and take everything before that index as the group key.
"""
s_lower = stem.lower()
positions = []
for marker in variant_markers:
m_lower = marker.lower()
idx = s_lower.find(m_lower)
if idx != -1:
positions.append(idx)
if not positions:
return stem
cutoff = min(positions)
if cutoff <= 0:
return stem
return stem[:cutoff]
def collect_groups_in_directory(directory: Path, variant_markers):
"""
Collect files into groups for a single directory (no recursion).
Returns a dict: {group_key: [Path, ...], ...}
"""
groups = {}
for entry in sorted(directory.iterdir()):
if not entry.is_file():
continue
# Skip dotfiles
if entry.name.startswith("."):
continue
stem = entry.stem
key = derive_group_key(stem, variant_markers)
groups.setdefault(key, []).append(entry)
return groups
# ----------------------------- GUI application ------------------------------
class MFRApp(tk.Tk):
def __init__(self):
super().__init__()
self.title("MemorySharing Multi-File Renamer")
self.geometry("900x600")
self.directory_var = tk.StringVar()
self.date_format_var = tk.StringVar(value="%Y_%m%d")
self.include_date_var = tk.BooleanVar(value=True)
# Camera tag dropdown, plus custom override
self.camera_tags = ["d600", "z6", "em5", "other"]
self.camera_tag_var = tk.StringVar(value=self.camera_tags[0])
self.camera_custom_var = tk.StringVar()
self.extra_text_var = tk.StringVar()
self.variant_markers_var = tk.StringVar(value=",".join(DEFAULT_VARIANT_MARKERS))
# Store preview mappings [(old_path, new_path), ...]
self.preview_mappings = []
self._build_ui()
def _build_ui(self):
# Top frame: directory selection
top_frame = ttk.Frame(self, padding=8)
top_frame.pack(fill=tk.X)
ttk.Label(top_frame, text="Directory:").grid(row=0, column=0, sticky=tk.W)
dir_entry = ttk.Entry(top_frame, textvariable=self.directory_var, width=60)
dir_entry.grid(row=0, column=1, sticky=tk.W, padx=4)
ttk.Button(top_frame, text="Browse...", command=self.select_directory).grid(
row=0, column=2, padx=4
)
# Date options
date_frame = ttk.LabelFrame(self, text="Date options", padding=8)
date_frame.pack(fill=tk.X, padx=8, pady=4)
ttk.Checkbutton(
date_frame, text="Include date in filename", variable=self.include_date_var
).grid(row=0, column=0, sticky=tk.W)
ttk.Label(date_frame, text="Date format (strftime):").grid(
row=1, column=0, sticky=tk.W, pady=(4, 0)
)
ttk.Entry(date_frame, textvariable=self.date_format_var, width=20).grid(
row=1, column=1, sticky=tk.W, padx=4, pady=(4, 0)
)
ttk.Label(date_frame, text="Example: %Y_%m%d → 2025_0401").grid(
row=1, column=2, sticky=tk.W, padx=8
)
# Camera / text options
name_frame = ttk.LabelFrame(self, text="Naming components", padding=8)
name_frame.pack(fill=tk.X, padx=8, pady=4)
ttk.Label(name_frame, text="Camera tag:").grid(row=0, column=0, sticky=tk.W)
camera_menu = ttk.OptionMenu(
name_frame, self.camera_tag_var, self.camera_tags[0], *self.camera_tags
)
camera_menu.grid(row=0, column=1, sticky=tk.W, padx=4)
ttk.Label(name_frame, text="Custom tag (optional):").grid(
row=1, column=0, sticky=tk.W, pady=(4, 0)
)
ttk.Entry(name_frame, textvariable=self.camera_custom_var, width=20).grid(
row=1, column=1, sticky=tk.W, padx=4, pady=(4, 0)
)
ttk.Label(name_frame, text="Extra text:").grid(
row=2, column=0, sticky=tk.W, pady=(4, 0)
)
ttk.Entry(name_frame, textvariable=self.extra_text_var, width=40).grid(
row=2, column=1, columnspan=2, sticky=tk.W, padx=4, pady=(4, 0)
)
ttk.Label(
name_frame,
text="(Spaces will be converted to '-' in the basename.)",
).grid(row=3, column=1, columnspan=2, sticky=tk.W, padx=4, pady=(2, 0))
# Variant markers
vm_frame = ttk.LabelFrame(self, text="Variant markers", padding=8)
vm_frame.pack(fill=tk.X, padx=8, pady=4)
ttk.Label(vm_frame, text="Variant suffixes (comma separated):").grid(
row=0, column=0, sticky=tk.W
)
ttk.Entry(vm_frame, textvariable=self.variant_markers_var, width=30).grid(
row=0, column=1, sticky=tk.W, padx=4
)
ttk.Label(vm_frame, text="Example: _tn,_ws").grid(
row=0, column=2, sticky=tk.W, padx=4
)
# Buttons
button_frame = ttk.Frame(self, padding=8)
button_frame.pack(fill=tk.X)
ttk.Button(button_frame, text="Preview", command=self.preview).pack(
side=tk.LEFT, padx=4
)
ttk.Button(button_frame, text="Rename", command=self.rename).pack(
side=tk.LEFT, padx=4
)
# Info about pattern
pattern_label = ttk.Label(
button_frame,
text=(
"Pattern: [date?]_[camera]_[extra]_[group-index] + variant + extension\n"
"Example: 2025_0401_d600_family_0001_tn.jpg"
),
justify=tk.LEFT,
)
pattern_label.pack(side=tk.LEFT, padx=16)
# Preview text area
text_frame = ttk.Frame(self, padding=8)
text_frame.pack(fill=tk.BOTH, expand=True)
self.preview_text = tk.Text(text_frame, wrap=tk.NONE)
self.preview_text.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
scrollbar_y = ttk.Scrollbar(text_frame, orient=tk.VERTICAL, command=self.preview_text.yview)
scrollbar_y.pack(side=tk.RIGHT, fill=tk.Y)
self.preview_text.configure(yscrollcommand=scrollbar_y.set)
# --------------------------- UI callbacks --------------------------------
def select_directory(self):
dirname = filedialog.askdirectory(title="Select directory to rename")
if dirname:
self.directory_var.set(dirname)
def _get_variant_markers(self):
raw = self.variant_markers_var.get().strip()
if not raw:
return DEFAULT_VARIANT_MARKERS
return [s.strip() for s in raw.split(",") if s.strip()]
def _compute_preview_mappings(self):
"""Compute the planned renames without performing them."""
self.preview_mappings = []
self.preview_text.delete("1.0", tk.END)
dir_str = self.directory_var.get().strip()
if not dir_str:
messagebox.showerror("Error", "Please select a directory.")
return
directory = Path(dir_str)
if not directory.is_dir():
messagebox.showerror("Error", f"Not a directory: {directory}")
return
try:
variant_markers = self._get_variant_markers()
except Exception as e:
messagebox.showerror("Error", f"Invalid variant markers: {e}")
return
groups = collect_groups_in_directory(directory, variant_markers)
if not groups:
self.preview_text.insert(tk.END, "No files found in directory.\n")
return
include_date = self.include_date_var.get()
date_format = self.date_format_var.get().strip()
camera_tag = self.camera_custom_var.get().strip() or self.camera_tag_var.get().strip()
extra_text = self.extra_text_var.get().strip().replace(" ", "-")
# Build name components that are static across groups
name_components = []
# date is per-group, so not included here
if camera_tag:
name_components.append(camera_tag)
if extra_text:
name_components.append(extra_text)
# Use a stable ordering of groups
group_keys = sorted(groups.keys())
seq_width = len(str(len(group_keys))) if len(group_keys) > 0 else 1
if seq_width < 4:
seq_width = 4
for idx, group_key in enumerate(group_keys, start=1):
files = groups[group_key]
# Find earliest logical timestamp in group
ts_list = [get_logical_timestamp(p) for p in files]
earliest_ts = min(ts_list)
dt = datetime.fromtimestamp(earliest_ts)
# Build base name
parts = []
if include_date:
try:
date_part = dt.strftime(date_format)
except Exception as e:
messagebox.showerror(
"Error",
f"Invalid date format '{date_format}': {e}",
)
self.preview_mappings = []
return
if date_part:
parts.append(date_part)
parts.extend(name_components)
seq_str = f"{idx:0{seq_width}d}"
parts.append(seq_str)
base = "_".join(p for p in parts if p)
# For each file in group, preserve variant suffix
for p in files:
old_stem = p.stem
suffix_part = ""
if old_stem.startswith(group_key):
suffix_part = old_stem[len(group_key) :]
new_stem = base + suffix_part
new_name = new_stem + p.suffix
new_path = p.with_name(new_name)
self.preview_mappings.append((p, new_path))
# Check for collisions (two different old paths yielding same new path)
dest_counts = {}
for old, new in self.preview_mappings:
dest_counts[new] = dest_counts.get(new, 0) + 1
collisions = [dst for dst, cnt in dest_counts.items() if cnt > 1]
if collisions:
self.preview_text.insert(
tk.END,
"WARNING: Naming collisions detected. Some new filenames are not unique.\n",
)
for c in collisions:
self.preview_text.insert(tk.END, f" -> {c}\n")
self.preview_text.insert(
tk.END,
"Please adjust options (e.g., extra text) to avoid collisions.\n\n",
)
# Print mapping
for old, new in self.preview_mappings:
if old == new:
continue
self.preview_text.insert(tk.END, f"{old.name} -> {new.name}\n")
if not self.preview_mappings:
self.preview_text.insert(tk.END, "No renames planned.\n")
def preview(self):
self._compute_preview_mappings()
def rename(self):
self._compute_preview_mappings()
if not self.preview_mappings:
messagebox.showinfo("Info", "No renames to apply.")
return
# Check for collisions again, and abort if any
dest_map = {}
for old, new in self.preview_mappings:
if old == new:
continue
if new in dest_map and dest_map[new] != old:
messagebox.showerror(
"Error",
f"Collision: multiple files would be renamed to {new.name}\n"
"Renaming aborted.",
)
return
dest_map[new] = old
if not messagebox.askyesno(
"Confirm rename",
"Apply the listed renames?\n\n"
"This will rename files on disk. It is recommended to have a backup.",
):
return
# Perform renames
errors = []
for old, new in self.preview_mappings:
if old == new:
continue
try:
old.rename(new)
except Exception as e:
errors.append((old, new, e))
if errors:
msg_lines = ["Some renames failed:"]
for old, new, e in errors:
msg_lines.append(f"{old} -> {new}: {e}")
messagebox.showerror("Errors during rename", "\n".join(msg_lines))
else:
messagebox.showinfo("Done", "Renaming completed successfully.")
# Refresh preview with new names (optional)
self.preview()
# ------------------------------ main ----------------------------------------
def main():
app = MFRApp()
app.mainloop()
if __name__ == "__main__":
main()