Skip to content

Instantly share code, notes, and snippets.

@kezzyhko
Last active December 3, 2025 16:33
Show Gist options
  • Select an option

  • Save kezzyhko/e11e6da0de014989a476f2d256b8de27 to your computer and use it in GitHub Desktop.

Select an option

Save kezzyhko/e11e6da0de014989a476f2d256b8de27 to your computer and use it in GitHub Desktop.
.npy image viewer
#!/usr/bin/python3
# https://gist.github.com/kezzyhko/e11e6da0de014989a476f2d256b8de27
from PIL import Image, ImageTk, PngImagePlugin
import sys
import numpy as np
import tkinter as tk
from tkinter import ttk, filedialog, simpledialog
from pathlib import Path
import subprocess
import os
import re
from collections import defaultdict
class ImageViewer:
def __init__(self, root, folder_path=None):
self.root = root
self.root.title("NPY Image Viewer")
# Set window size - smaller and resizable
self.root.geometry("900x700")
self.root.minsize(700, 500)
# Store original image for responsive resizing
self.original_image = None
self.resize_job = None
# Track all active tooltips for cleanup
self.active_tooltips = []
# Configure style for UI elements - will be adjusted responsively
style = ttk.Style()
self.base_font_size = 12
self.button_font_size = 11
style.configure('Large.TButton', font=('Arial', self.button_font_size), padding=8)
# If no folder path provided, prompt user to select one
if folder_path is None:
folder_path = filedialog.askdirectory(
title="Select folder containing .npy files",
initialdir=os.getcwd()
)
if not folder_path:
print("No folder selected. Exiting...")
sys.exit(0)
self.folder_path = folder_path
# Find all .npy and .png files recursively
self.npy_files = sorted(Path(folder_path).rglob("*.npy"))
self.png_files = sorted(Path(folder_path).rglob("*.png"))
self.all_files = sorted(list(self.npy_files) + list(self.png_files))
if not self.all_files:
print(f"No .npy or .png files found in {folder_path}")
sys.exit(1)
# File selection/marking system
self.marked_files = set() # Set of file paths that are marked
# Build image version groups (for toggling between versions like color/depth)
self.build_version_groups()
# Current logical image index and current version
self.current_image_index = 0
self.current_version = None
# Set initial version if available
if self.logical_images:
first_image = self.logical_images[0]
self.current_version = first_image['versions'][0]['name']
# Top frame for filename and actions
top_frame = tk.Frame(root, pady=15)
top_frame.pack(fill=tk.X, padx=20)
# Filename label - clickable to open location
self.filename_font_size = 13
self.filename_label = tk.Label(
top_frame,
text="",
font=("Arial", self.filename_font_size, "bold"),
fg="blue",
cursor="hand2",
wraplength=800,
justify=tk.LEFT
)
self.filename_label.pack(anchor=tk.W)
self.filename_label.bind("<Button-1>", self.open_file_location)
# Tooltip for filename
self.create_tooltip(self.filename_label, "Click to open file location")
# Action buttons frame (Copy Path, Refresh) - allow wrapping
action_frame = tk.Frame(top_frame)
action_frame.pack(anchor=tk.W, pady=10, fill=tk.X)
self.copy_button = ttk.Button(
action_frame,
text="📋 Copy Full Path",
command=self.copy_path,
style='Large.TButton'
)
self.copy_button.pack(side=tk.LEFT, padx=5)
self.create_tooltip(self.copy_button, "Copy the full file path to clipboard")
self.refresh_button = ttk.Button(
action_frame,
text="🔄 Refresh",
command=self.refresh_files,
style='Large.TButton'
)
self.refresh_button.pack(side=tk.LEFT, padx=5)
self.create_tooltip(self.refresh_button, "Manually rescan folder for new .npy files")
self.change_folder_button = ttk.Button(
action_frame,
text="📁 Change Folder",
command=self.change_folder,
style='Large.TButton'
)
self.change_folder_button.pack(side=tk.LEFT, padx=5)
self.create_tooltip(self.change_folder_button, "Select a different folder to view")
self.export_button = ttk.Button(
action_frame,
text="💾 Export as PNG",
command=self.export_folder,
style='Large.TButton'
)
self.export_button.pack(side=tk.LEFT, padx=5)
self.create_tooltip(self.export_button, "Export all .npy files as PNG images")
self.mark_button = ttk.Button(
action_frame,
text="✓ Mark File",
command=self.toggle_mark_current_file,
style='Large.TButton'
)
self.mark_button.pack(side=tk.LEFT, padx=5)
self.create_tooltip(self.mark_button, "Mark/unmark current file for export (or press 'M')")
self.export_marked_button = ttk.Button(
action_frame,
text="💾 Export Marked as NPY",
command=self.export_marked_files,
style='Large.TButton'
)
self.export_marked_button.pack(side=tk.LEFT, padx=5)
self.create_tooltip(self.export_marked_button, f"Export {len(self.marked_files)} marked file(s) as .npy")
# Marked files counter
self.marked_count_label = tk.Label(
action_frame,
text="",
font=("Arial", 11),
fg="blue"
)
self.marked_count_label.pack(side=tk.LEFT, padx=10)
self.update_marked_count()
# Status label for feedback
self.status_font_size = 11
self.status_label = tk.Label(
action_frame,
text="",
font=("Arial", self.status_font_size),
fg="green"
)
self.status_label.pack(side=tk.LEFT, padx=15)
# Create a container for image and controls to ensure proper space allocation
content_frame = tk.Frame(root)
content_frame.pack(fill=tk.BOTH, expand=True, padx=10, pady=10)
# Image display area
self.image_label = tk.Label(content_frame, bg="#f0f0f0")
self.image_label.pack(padx=10, pady=10, fill=tk.BOTH, expand=True)
# Navigation buttons frame
button_frame = tk.Frame(content_frame)
button_frame.pack(pady=10, fill=tk.X)
# Left side: navigation buttons
nav_buttons_frame = tk.Frame(button_frame)
nav_buttons_frame.pack(side=tk.LEFT, expand=True)
self.prev_button = ttk.Button(
nav_buttons_frame,
text="← Previous",
command=self.show_previous,
style='Large.TButton',
width=15
)
self.prev_button.pack(side=tk.LEFT, padx=5)
self.next_button = ttk.Button(
nav_buttons_frame,
text="Next →",
command=self.show_next,
style='Large.TButton',
width=15
)
self.next_button.pack(side=tk.LEFT, padx=5)
# Right side: Jump to image controls
jump_frame = tk.Frame(button_frame)
jump_frame.pack(side=tk.RIGHT, padx=10)
self.jump_font_size = 11
self.jump_label = tk.Label(jump_frame, text="Jump to:", font=("Arial", self.jump_font_size))
self.jump_label.pack(side=tk.LEFT, padx=5)
self.jump_entry = tk.Entry(jump_frame, width=8, font=("Arial", self.jump_font_size))
self.jump_entry.pack(side=tk.LEFT, padx=5)
self.jump_entry.bind("<Return>", lambda e: self.jump_to_image())
# Allow arrow keys to work even when entry has focus (for navigation)
def handle_left(e):
self.show_previous()
return "break"
def handle_right(e):
self.show_next()
return "break"
def handle_up(e):
self.next_version()
return "break"
def handle_down(e):
self.previous_version()
return "break"
self.jump_entry.bind("<Left>", handle_left)
self.jump_entry.bind("<Right>", handle_right)
self.jump_entry.bind("<Up>", handle_up)
self.jump_entry.bind("<Down>", handle_down)
jump_button = ttk.Button(
jump_frame,
text="Go",
command=self.jump_to_image,
style='Large.TButton',
width=6
)
jump_button.pack(side=tk.LEFT, padx=5)
self.create_tooltip(jump_button, "Jump to specific image number (or press Enter)")
# Counter and resolution label - ensure it's always visible at bottom
info_frame = tk.Frame(root)
info_frame.pack(pady=(5, 10), fill=tk.X, padx=10, side=tk.BOTTOM)
# Set minimum height to ensure content is always visible (2 rows + padding)
info_frame.config(height=80)
info_frame.pack_propagate(False) # Prevent frame from shrinking
# Top row: counter and resolution - allow wrapping
info_top_frame = tk.Frame(info_frame)
info_top_frame.pack(fill=tk.X, pady=3, padx=5)
self.counter_font_size = 12
self.counter_label = tk.Label(
info_top_frame,
text="",
font=("Arial", self.counter_font_size, "bold"),
anchor=tk.W,
justify=tk.LEFT,
wraplength=300
)
self.counter_label.pack(side=tk.LEFT, padx=5, anchor=tk.W)
self.resolution_font_size = 11
self.resolution_label = tk.Label(
info_top_frame,
text="",
font=("Arial", self.resolution_font_size),
fg="#0066cc",
anchor=tk.W,
justify=tk.LEFT,
wraplength=400
)
self.resolution_label.pack(side=tk.LEFT, padx=5, anchor=tk.W)
# Bottom row: Version switcher (can wrap if needed)
self.version_frame = tk.Frame(info_frame)
self.version_frame.pack(fill=tk.X, pady=3, padx=5)
self.version_font_size = 11
self.version_label = tk.Label(
self.version_frame,
text="",
font=("Arial", self.version_font_size, "bold"),
fg="#ff6600",
anchor=tk.W,
justify=tk.LEFT
)
self.version_label.pack(side=tk.LEFT, padx=5, anchor=tk.W)
self.version_buttons_frame = tk.Frame(self.version_frame)
self.version_buttons_frame.pack(side=tk.LEFT, padx=5, fill=tk.X, expand=True)
# This will hold dynamically created version buttons
self.version_buttons = {}
# Keyboard bindings - use bind_all for Windows compatibility
# This ensures bindings work even when other widgets have focus
self.root.bind_all("<Left>", lambda e: self.show_previous())
self.root.bind_all("<Right>", lambda e: self.show_next())
self.root.bind_all("<Up>", lambda e: self.next_version())
self.root.bind_all("<Down>", lambda e: self.previous_version())
self.root.bind_all("<KeyPress-r>", lambda e: self.refresh_files())
self.root.bind_all("<KeyPress-R>", lambda e: self.refresh_files())
self.root.bind_all("<KeyPress-c>", lambda e: self.copy_path())
self.root.bind_all("<KeyPress-C>", lambda e: self.copy_path())
self.root.bind_all("<KeyPress-f>", lambda e: self.change_folder())
self.root.bind_all("<KeyPress-F>", lambda e: self.change_folder())
self.root.bind_all("<KeyPress-e>", lambda e: self.export_folder())
self.root.bind_all("<KeyPress-E>", lambda e: self.export_folder())
self.root.bind_all("<KeyPress-m>", lambda e: self.toggle_mark_current_file())
self.root.bind_all("<KeyPress-M>", lambda e: self.toggle_mark_current_file())
# Also bind to root window for additional compatibility
self.root.bind("<Left>", lambda e: self.show_previous())
self.root.bind("<Right>", lambda e: self.show_next())
self.root.bind("<Up>", lambda e: self.next_version())
self.root.bind("<Down>", lambda e: self.previous_version())
self.root.bind("<KeyPress-r>", lambda e: self.refresh_files())
self.root.bind("<KeyPress-R>", lambda e: self.refresh_files())
self.root.bind("<KeyPress-c>", lambda e: self.copy_path())
self.root.bind("<KeyPress-C>", lambda e: self.copy_path())
self.root.bind("<KeyPress-f>", lambda e: self.change_folder())
self.root.bind("<KeyPress-F>", lambda e: self.change_folder())
self.root.bind("<KeyPress-e>", lambda e: self.export_folder())
self.root.bind("<KeyPress-E>", lambda e: self.export_folder())
self.root.bind("<KeyPress-m>", lambda e: self.toggle_mark_current_file())
self.root.bind("<KeyPress-M>", lambda e: self.toggle_mark_current_file())
# Ensure root window can receive focus for keyboard events
self.root.focus_set()
# Handle window close event to cleanup
self.root.protocol("WM_DELETE_WINDOW", self.on_closing)
# Bind window resize event for responsive layout
self.root.bind("<Configure>", self.on_window_resize)
# Ensure window can receive keyboard focus (important for Windows)
# Use after() to set focus after window is fully created
self.root.after(100, lambda: self.root.focus_force())
# Bind focus events to maintain keyboard focus on Windows
self.root.bind("<FocusIn>", lambda e: self.root.focus_set())
# Load and display the first image
self.load_image()
def create_tooltip(self, widget, text):
"""Create a tooltip for a widget"""
tooltip_window = [None] # Use list to maintain reference in closure
tooltip_job = [None] # Track delayed tooltip creation
def destroy_tooltip():
"""Destroy tooltip and remove from active list"""
if tooltip_window[0] is not None:
try:
if tooltip_window[0] in self.active_tooltips:
self.active_tooltips.remove(tooltip_window[0])
tooltip_window[0].destroy()
except:
pass
tooltip_window[0] = None
def show_tooltip(event):
"""Show tooltip after a short delay"""
# Cancel any pending tooltip creation
if tooltip_job[0] is not None:
self.root.after_cancel(tooltip_job[0])
tooltip_job[0] = None
# Destroy any existing tooltip for this widget
destroy_tooltip()
# Destroy all other tooltips to prevent piling
self._cleanup_all_tooltips()
# Schedule tooltip creation after delay
def create_tooltip():
try:
tooltip = tk.Toplevel()
tooltip.wm_overrideredirect(True)
tooltip.wm_geometry(f"+{event.x_root+10}+{event.y_root+10}")
label = tk.Label(
tooltip,
text=text,
background="yellow",
relief=tk.SOLID,
borderwidth=1,
font=("Arial", 11)
)
label.pack()
tooltip_window[0] = tooltip
self.active_tooltips.append(tooltip)
# Auto-destroy tooltip after 5 seconds as safety measure
self.root.after(5000, destroy_tooltip)
except:
pass
tooltip_job[0] = self.root.after(500, create_tooltip) # 500ms delay
def hide_tooltip(event):
"""Hide tooltip when mouse leaves"""
# Cancel pending tooltip creation
if tooltip_job[0] is not None:
self.root.after_cancel(tooltip_job[0])
tooltip_job[0] = None
# Destroy tooltip
destroy_tooltip()
def on_motion(event):
"""Update tooltip position if mouse moves while tooltip is shown"""
if tooltip_window[0] is not None:
try:
tooltip_window[0].wm_geometry(f"+{event.x_root+10}+{event.y_root+10}")
except:
pass
widget.bind("<Enter>", show_tooltip)
widget.bind("<Leave>", hide_tooltip)
widget.bind("<Motion>", on_motion)
def _cleanup_all_tooltips(self):
"""Destroy all active tooltips"""
for tooltip in self.active_tooltips[:]: # Copy list to avoid modification during iteration
try:
tooltip.destroy()
except:
pass
self.active_tooltips.clear()
def parse_filename(self, filepath):
"""
Parse filename to extract version and base name.
Pattern: {version}_image_{id}_{date}_{time}.npy
Example: color_image_0001_20251124_085806.npy
Returns: (version, base_name) or (None, filename) if pattern doesn't match
"""
filename = filepath.stem
# Try to match the pattern: {version}_image_{rest}
match = re.match(r'^(.+?)_image_(.+)$', filename)
if match:
version = match.group(1)
base = f"image_{match.group(2)}"
return (version, base)
# If pattern doesn't match, return None as version
return (None, filename)
def build_version_groups(self):
"""Build logical images with multiple versions (supports .npy and .png files)"""
# Group files by base name (without version)
groups = defaultdict(list)
files_without_version = []
# Process both .npy and .png files
for file_path in self.all_files:
version, base_name = self.parse_filename(file_path)
if version:
key = (file_path.parent, base_name)
groups[key].append({'name': version, 'path': file_path})
else:
# Files that don't match the pattern - treat as single-version images
files_without_version.append(file_path)
# Create logical images list
self.logical_images = []
# Add grouped images (with versions)
for key, versions in sorted(groups.items()):
versions.sort(key=lambda x: x['name']) # Sort versions alphabetically
self.logical_images.append({
'base_name': key[1],
'parent': key[0],
'versions': versions,
'has_versions': True
})
# Add ungrouped images (without versions)
for npy_file in sorted(files_without_version):
self.logical_images.append({
'base_name': npy_file.stem,
'parent': npy_file.parent,
'versions': [{'name': None, 'path': npy_file}],
'has_versions': False
})
# Sort logical images by parent and base name
self.logical_images.sort(key=lambda x: (str(x['parent']), x['base_name']))
def get_current_versions(self):
"""Get all available versions for the current image"""
if not self.logical_images or self.current_image_index >= len(self.logical_images):
return []
current_image = self.logical_images[self.current_image_index]
return [(v['name'], v['path']) for v in current_image['versions']]
def switch_to_version(self, version_name):
"""Switch to a specific version of the current image"""
if not self.logical_images or self.current_image_index >= len(self.logical_images):
return
current_image = self.logical_images[self.current_image_index]
# Check if this version exists
version_exists = any(v['name'] == version_name for v in current_image['versions'])
if version_exists:
self.current_version = version_name
self.load_image()
if version_name:
self.show_status(f"✓ Switched to {version_name} version", 2000)
else:
self.show_status(f"⚠ Version {version_name} not available", 2000, "orange")
def next_version(self):
"""Switch to next version of current image"""
if not self.logical_images or self.current_image_index >= len(self.logical_images):
return
current_image = self.logical_images[self.current_image_index]
versions = current_image['versions']
if len(versions) <= 1:
return
# Find current version index
current_idx = next((i for i, v in enumerate(versions) if v['name'] == self.current_version), 0)
# Move to next version
next_idx = (current_idx + 1) % len(versions)
next_version = versions[next_idx]['name']
self.switch_to_version(next_version)
def previous_version(self):
"""Switch to previous version of current image"""
if not self.logical_images or self.current_image_index >= len(self.logical_images):
return
current_image = self.logical_images[self.current_image_index]
versions = current_image['versions']
if len(versions) <= 1:
return
# Find current version index
current_idx = next((i for i, v in enumerate(versions) if v['name'] == self.current_version), 0)
# Move to previous version
prev_idx = (current_idx - 1) % len(versions)
prev_version = versions[prev_idx]['name']
self.switch_to_version(prev_version)
def update_version_ui(self):
"""Update the version switcher UI to show available versions"""
# Clear existing version buttons
for widget in self.version_buttons.values():
widget.destroy()
self.version_buttons.clear()
if not self.logical_images or self.current_image_index >= len(self.logical_images):
self.version_label.config(text="")
return
current_image = self.logical_images[self.current_image_index]
versions = current_image['versions']
if len(versions) <= 1:
# No versions or only one version, hide the UI
self.version_label.config(text="")
return
self.version_label.config(text="📸 Versions:")
# Create button for each version
for version_info in versions:
version = version_info['name']
is_current = (version == self.current_version)
btn = tk.Button(
self.version_buttons_frame,
text=version,
font=("Arial", 10, "bold" if is_current else "normal"),
bg="#4CAF50" if is_current else "#e0e0e0",
fg="white" if is_current else "black",
relief=tk.SUNKEN if is_current else tk.RAISED,
padx=8,
pady=4,
command=lambda v=version: self.switch_to_version(v)
)
btn.pack(side=tk.LEFT, padx=2)
self.version_buttons[version] = btn
# Add tooltip
self.create_tooltip(btn, f"Switch to {version} version (or use Up/Down arrows)")
def on_closing(self):
"""Cleanup when closing the window"""
# Cleanup all tooltips
self._cleanup_all_tooltips()
self.root.destroy()
def get_current_file_path(self):
"""Get the path of the currently displayed file"""
if not self.logical_images or self.current_image_index >= len(self.logical_images):
return None
current_image = self.logical_images[self.current_image_index]
# Find the file for the current version
for version_info in current_image['versions']:
if version_info['name'] == self.current_version:
return version_info['path']
# If current version not found, return first version
return current_image['versions'][0]['path']
def open_file_location(self, event=None):
"""Open file manager at the current file's location"""
current_file = self.get_current_file_path()
if not current_file:
return
folder = current_file.parent
try:
# Linux
if sys.platform.startswith('linux'):
subprocess.Popen(['xdg-open', str(folder)])
# macOS
elif sys.platform == 'darwin':
subprocess.Popen(['open', str(folder)])
# Windows
elif sys.platform == 'win32':
subprocess.Popen(['explorer', str(folder)])
self.show_status("Opened file location", 2000)
except Exception as e:
self.show_status(f"Error opening location: {e}", 3000, "red")
def copy_path(self):
"""Copy the full path to clipboard"""
current_file = self.get_current_file_path()
if not current_file:
return
full_path = str(current_file.absolute())
self.root.clipboard_clear()
self.root.clipboard_append(full_path)
self.root.update() # Keep clipboard data after window closes
self.show_status("✓ Path copied to clipboard!", 2000)
def refresh_files(self):
"""Manually rescan folder for new .npy and .png files"""
old_count = len(self.logical_images)
old_base_name = None
if self.logical_images and self.current_image_index < len(self.logical_images):
old_base_name = self.logical_images[self.current_image_index]['base_name']
# Rescan for .npy and .png files
self.npy_files = sorted(Path(self.folder_path).rglob("*.npy"))
self.png_files = sorted(Path(self.folder_path).rglob("*.png"))
self.all_files = sorted(list(self.npy_files) + list(self.png_files))
if len(self.npy_files) == 0:
self.show_status("⚠ No .npy files found!", 3000, "red")
return
# Rebuild version groups
self.build_version_groups()
new_count = len(self.logical_images)
# Try to maintain position on the same logical image
if old_base_name:
found = False
for i, img in enumerate(self.logical_images):
if img['base_name'] == old_base_name:
self.current_image_index = i
found = True
break
if not found:
# Current image no longer exists, stay at same index or adjust
self.current_image_index = min(self.current_image_index, new_count - 1)
else:
self.current_image_index = 0
# Ensure current version exists in new image
if self.logical_images and self.current_image_index < len(self.logical_images):
current_image = self.logical_images[self.current_image_index]
version_exists = any(v['name'] == self.current_version for v in current_image['versions'])
if not version_exists:
self.current_version = current_image['versions'][0]['name']
# Show feedback
if new_count > old_count:
diff = new_count - old_count
self.show_status(f"✓ Manual refresh: Found {diff} new image{'s' if diff > 1 else ''}!", 3000, "green")
elif new_count < old_count:
diff = old_count - new_count
self.show_status(f"⚠ Manual refresh: {diff} image{'s' if diff > 1 else ''} removed", 3000, "orange")
else:
self.show_status("✓ Manual refresh: No changes", 2000)
self.load_image()
def change_folder(self):
"""Open folder selection dialog to change the working folder"""
new_folder_path = filedialog.askdirectory(
title="Select folder containing .npy files",
initialdir=self.folder_path
)
if not new_folder_path:
# User cancelled the dialog
return
# Check if new folder has .npy or .png files
new_npy_files = sorted(Path(new_folder_path).rglob("*.npy"))
new_png_files = sorted(Path(new_folder_path).rglob("*.png"))
new_all_files = sorted(list(new_npy_files) + list(new_png_files))
if not new_all_files:
self.show_status("⚠ No .npy or .png files found in selected folder!", 3000, "red")
return
# Update folder path and file list
self.folder_path = new_folder_path
self.npy_files = new_npy_files
self.png_files = new_png_files
self.all_files = new_all_files
# Rebuild version groups for new folder
self.build_version_groups()
# Reset to first image
self.current_image_index = 0
if self.logical_images:
self.current_version = self.logical_images[0]['versions'][0]['name']
# Update window title to show new folder
folder_name = Path(new_folder_path).name or new_folder_path
self.root.title(f"NPY Image Viewer - {folder_name}")
self.show_status(f"✓ Changed to folder with {len(self.logical_images)} image{'s' if len(self.logical_images) != 1 else ''}", 3000, "green")
self.load_image()
def show_status(self, message, duration_ms, color="green"):
"""Show a temporary status message"""
self.status_label.config(text=message, fg=color)
self.root.after(duration_ms, lambda: self.status_label.config(text=""))
def on_window_resize(self, event=None):
"""Handle window resize to make UI responsive"""
if event and event.widget == self.root:
window_width = self.root.winfo_width()
window_height = self.root.winfo_height()
if window_width < 100 or window_height < 100:
return # Window not ready yet
# Update filename label wraplength based on window width
self.filename_label.config(wraplength=max(300, window_width - 100))
# Update info label wraplengths based on window width
if hasattr(self, 'counter_label'):
self.counter_label.config(wraplength=max(200, window_width // 3))
if hasattr(self, 'resolution_label'):
self.resolution_label.config(wraplength=max(300, window_width // 2))
# Adjust font sizes based on window size
self._adjust_font_sizes(window_width, window_height)
# Debounce resize events - only resize image after user stops resizing
if self.resize_job:
self.root.after_cancel(self.resize_job)
# Schedule resize after a short delay (150ms)
self.resize_job = self.root.after(150, self._perform_resize)
def _adjust_font_sizes(self, width, height):
"""Adjust font sizes based on window dimensions"""
# Calculate scale factor (base on 900x700 window)
width_scale = width / 900.0
height_scale = height / 700.0
scale = min(width_scale, height_scale, 1.2) # Cap at 1.2x
# Adjust fonts proportionally
self.filename_font_size = max(10, int(13 * scale))
self.button_font_size = max(9, int(11 * scale))
self.counter_font_size = max(10, int(12 * scale))
self.resolution_font_size = max(9, int(11 * scale))
self.version_font_size = max(9, int(11 * scale))
self.jump_font_size = max(9, int(11 * scale))
self.status_font_size = max(9, int(11 * scale))
# Update fonts
self.filename_label.config(font=("Arial", self.filename_font_size, "bold"))
self.counter_label.config(font=("Arial", self.counter_font_size, "bold"))
self.resolution_label.config(font=("Arial", self.resolution_font_size))
self.version_label.config(font=("Arial", self.version_font_size, "bold"))
self.status_label.config(font=("Arial", self.status_font_size))
self.jump_entry.config(font=("Arial", self.jump_font_size))
if hasattr(self, 'jump_label'):
self.jump_label.config(font=("Arial", self.jump_font_size))
# Update button style
style = ttk.Style()
style.configure('Large.TButton', font=('Arial', self.button_font_size), padding=8)
# Update version buttons
for btn in self.version_buttons.values():
btn.config(font=("Arial", self.version_font_size))
def _perform_resize(self):
"""Actually perform the image resize after debounce delay"""
if self.original_image is not None:
self.resize_and_display_image()
def load_image(self):
if not self.logical_images:
return
if self.current_image_index >= len(self.logical_images):
self.current_image_index = len(self.logical_images) - 1
current_image = self.logical_images[self.current_image_index]
# Find the file for the current version
current_file = None
for version_info in current_image['versions']:
if version_info['name'] == self.current_version:
current_file = version_info['path']
break
# If current version not found, use first available version
if current_file is None:
current_file = current_image['versions'][0]['path']
self.current_version = current_image['versions'][0]['name']
# Update filename label with just the filename (path shown in tooltip)
filename_only = current_file.name
parent_path = str(current_file.parent)
display_text = f"{filename_only}\n📁 {parent_path}"
self.filename_label.config(text=display_text)
# Update counter - show logical image count
self.counter_label.config(
text=f"Image {self.current_image_index + 1} of {len(self.logical_images)}"
)
try:
# Load numpy array (from .npy or .png)
if current_file.suffix.lower() == '.png':
img_array = self.load_png_as_numpy(current_file)
else:
img_array = np.load(current_file)
# Display resolution
if len(img_array.shape) == 3:
height, width, channels = img_array.shape
self.resolution_label.config(text=f"📐 Resolution: {width}×{height}×{channels}")
elif len(img_array.shape) == 2:
height, width = img_array.shape
self.resolution_label.config(text=f"📐 Resolution: {width}×{height}")
else:
self.resolution_label.config(text=f"📐 Shape: {img_array.shape}")
# Normalize and convert to PIL Image (handles depth images properly)
im = self.normalize_image_for_export(img_array, current_file.name)
# Store original image for responsive resizing
self.original_image = im.copy()
# Resize image to fit window
self.resize_and_display_image()
except Exception as e:
self.image_label.config(
text=f"Error loading image: {e}",
font=("Arial", 14),
fg="red"
)
self.resolution_label.config(text="")
# Update button states
self.prev_button.config(state=tk.NORMAL if self.current_image_index > 0 else tk.DISABLED)
self.next_button.config(state=tk.NORMAL if self.current_image_index < len(self.logical_images) - 1 else tk.DISABLED)
# Update version switcher UI
self.update_version_ui()
# Update mark button state
self.update_mark_button_state()
def resize_and_display_image(self):
"""Resize and display the stored image based on current window size"""
if self.original_image is None:
return
try:
# Get available space for image display
window_width = self.root.winfo_width()
window_height = self.root.winfo_height()
# Account for padding and other UI elements (approximately)
# Top frame: ~150px, buttons: ~100px, info: ~80px (2 rows), padding: ~60px
available_width = max(400, window_width - 80)
available_height = max(300, window_height - 350) # More space for info frame
# Create a copy to resize
im = self.original_image.copy()
# Resize to fit available space while maintaining aspect ratio
max_size = (available_width, available_height)
im.thumbnail(max_size, Image.Resampling.LANCZOS)
# Convert to PhotoImage for tkinter
self.photo = ImageTk.PhotoImage(im)
self.image_label.config(image=self.photo, text="")
except Exception as e:
# If resize fails, try to display original
try:
self.photo = ImageTk.PhotoImage(self.original_image)
self.image_label.config(image=self.photo, text="")
except:
self.image_label.config(
text=f"Error displaying image: {e}",
font=("Arial", 12),
fg="red"
)
def show_previous(self):
if self.current_image_index > 0:
self.current_image_index -= 1
self.load_image()
def show_next(self):
if self.current_image_index < len(self.logical_images) - 1:
self.current_image_index += 1
self.load_image()
def jump_to_image(self):
"""Jump to a specific image by number"""
try:
jump_num = int(self.jump_entry.get())
if 1 <= jump_num <= len(self.logical_images):
self.current_image_index = jump_num - 1
self.load_image()
self.jump_entry.delete(0, tk.END)
self.show_status(f"✓ Jumped to image {jump_num}", 2000)
else:
self.show_status(f"⚠ Please enter a number between 1 and {len(self.logical_images)}", 3000, "red")
except ValueError:
self.show_status("⚠ Please enter a valid number", 2000, "red")
def load_png_as_numpy(self, png_path):
"""Load PNG file and convert to numpy array, preserving data types
Restores original depth values from metadata if available (lossless restoration)"""
try:
img = Image.open(png_path)
arr = np.array(img)
# Preserve data types based on PNG bit depth
if img.mode == 'I;16': # 16-bit grayscale
arr = arr.astype(np.uint16)
# Check if this is a scaled depth image with restoration metadata
# Metadata can be in img.info (when loaded) or img.text (PNG text chunks)
metadata_source = img.text if hasattr(img, 'text') and img.text else img.info
if 'npy_scaled' in metadata_source and metadata_source.get('npy_scaled') == 'true':
# Restore original depth range from metadata
try:
original_min = int(metadata_source.get('npy_original_min', 0))
original_max = int(metadata_source.get('npy_original_max', 65535))
if original_max > original_min:
# Restore original values by reversing the scaling
# Use INTEGER arithmetic only for perfect lossless conversion
# scaled = ((original - min) * 65534 + range/2) // range + 1
# original = ((scaled - 1) * range + 32767) // 65534 + min
restored = arr.astype(np.uint32).copy() # Use uint32 for intermediate calculations
# Calculate range
range_size = original_max - original_min
# Restore non-zero values (scaled values are in range 1-65535)
non_zero_mask = arr > 0
# Reverse the integer scaling with rounding
restored[non_zero_mask] = (((arr[non_zero_mask].astype(np.uint32) - 1) * range_size + 32767) //
65534 + original_min).astype(np.uint16)
# Preserve original zeros (where arr == 0)
restored[arr == 0] = 0
restored = restored.astype(np.uint16)
return restored
except (ValueError, KeyError):
# If metadata is corrupted, return as-is
pass
return arr
elif img.mode == 'L': # 8-bit grayscale
return arr.astype(np.uint8)
elif img.mode == 'RGB':
# Check bit depth - PIL loads as uint8 by default, but we preserve what we can
# If it was originally 16-bit, it's already been converted to 8-bit by PIL
return arr.astype(np.uint8)
else:
# Default to uint8
return arr.astype(np.uint8)
except Exception as e:
raise Exception(f"Error loading PNG: {e}")
def toggle_mark_current_file(self):
"""Mark or unmark the current file for export"""
current_file = self.get_current_file_path()
if not current_file:
return
file_key = str(current_file.absolute())
if file_key in self.marked_files:
self.marked_files.remove(file_key)
self.show_status("✓ File unmarked", 1500)
else:
self.marked_files.add(file_key)
self.show_status("✓ File marked", 1500)
self.update_mark_button_state()
self.update_marked_count()
def update_mark_button_state(self):
"""Update the mark button appearance based on whether current file is marked"""
current_file = self.get_current_file_path()
if not current_file:
self.mark_button.config(text="✓ Mark File", state=tk.DISABLED)
return
file_key = str(current_file.absolute())
is_marked = file_key in self.marked_files
if is_marked:
self.mark_button.config(text="✗ Unmark File", state=tk.NORMAL)
else:
self.mark_button.config(text="✓ Mark File", state=tk.NORMAL)
def update_marked_count(self):
"""Update the marked files counter label"""
count = len(self.marked_files)
if count > 0:
self.marked_count_label.config(text=f"📌 {count} file(s) marked", fg="blue")
self.export_marked_button.config(state=tk.NORMAL)
else:
self.marked_count_label.config(text="", fg="blue")
self.export_marked_button.config(state=tk.DISABLED)
def export_marked_files(self):
"""Export all marked files as .npy files"""
if not self.marked_files:
self.show_status("⚠ No files marked!", 2000, "orange")
return
# Ask for output directory
output_dir = filedialog.askdirectory(
title="Select output directory for .npy exports",
initialdir=self.folder_path
)
if not output_dir:
return
# Create a progress window
progress_window = tk.Toplevel(self.root)
progress_window.title("Exporting Marked Files")
progress_window.geometry("500x150")
progress_window.transient(self.root)
progress_window.grab_set()
progress_label = tk.Label(
progress_window,
text="Starting export...",
font=("Arial", 12),
pady=20
)
progress_label.pack()
progress_bar = ttk.Progressbar(
progress_window,
length=400,
mode='determinate',
maximum=len(self.marked_files)
)
progress_bar.pack(pady=10)
status_label = tk.Label(
progress_window,
text="",
font=("Arial", 10),
fg="gray"
)
status_label.pack()
progress_window.update()
# Convert folder_path to Path for easier manipulation
folder_path_obj = Path(self.folder_path)
output_path = Path(output_dir)
exported_count = 0
failed_files = []
for idx, file_key in enumerate(self.marked_files):
try:
source_file = Path(file_key)
# Update progress
progress_label.config(text=f"Exporting {idx + 1} of {len(self.marked_files)}...")
status_label.config(text=f"File: {source_file.name}")
progress_bar['value'] = idx + 1
progress_window.update()
# Load the file (PNG or NPY)
if source_file.suffix.lower() == '.png':
img_array = self.load_png_as_numpy(source_file)
else:
img_array = np.load(source_file)
# Get relative path from original folder to preserve structure
try:
relative_path = source_file.relative_to(folder_path_obj)
except ValueError:
# If file is not relative to folder_path, use just the filename
relative_path = Path(source_file.name)
# Create output path preserving folder structure, change extension to .npy
output_filepath = output_path / relative_path.parent / (source_file.stem + ".npy")
# Create parent directories if they don't exist
output_filepath.parent.mkdir(parents=True, exist_ok=True)
# Save as .npy
np.save(output_filepath, img_array)
exported_count += 1
except Exception as e:
failed_files.append((Path(file_key).name, str(e)))
# Close progress window
progress_window.destroy()
total_marked = len(self.marked_files)
# Clear marked files after successful export
if exported_count > 0:
self.marked_files.clear()
self.update_marked_count()
self.update_mark_button_state()
# Show summary
if failed_files:
summary_msg = f"✓ Exported {exported_count}/{total_marked} files\n⚠ {len(failed_files)} failed"
self.show_status(summary_msg, 5000, "orange")
print(f"\n{summary_msg}")
for filename, error in failed_files:
print(f" Failed: {filename} - {error}")
else:
summary_msg = f"✓ Successfully exported all {exported_count} marked file(s) as .npy!"
self.show_status(summary_msg, 4000, "green")
print(summary_msg)
def is_png_valid(self, png_path):
"""Check if a PNG file is valid and not corrupted"""
try:
if not png_path.exists() or png_path.stat().st_size == 0:
return False
# Try to open and fully load the PNG to ensure it's not corrupted
# verify() checks structure, but we also need to ensure data is readable
with Image.open(png_path) as img:
img.verify() # Verify file structure
# Reopen to actually load the image data (verify() closes the file)
with Image.open(png_path) as img:
img.load() # Force loading of image data to catch corruption
return True
except Exception:
return False
def scan_existing_exports(self, output_path, folder_path_obj, progress_callback=None):
"""Scan existing exported PNG files and return a set of valid exported files
Args:
output_path: Path to the output directory
folder_path_obj: Path to the source folder (for reference)
progress_callback: Optional callback function(current, total, filename) for progress updates
"""
existing_exports = set()
broken_files = []
if not output_path.exists():
return existing_exports, broken_files
# First, collect all PNG files to know the total count
all_png_files = list(output_path.rglob("*.png"))
total_files = len(all_png_files)
# Find all PNG files in the output directory
for idx, png_file in enumerate(all_png_files):
# Update progress if callback provided
if progress_callback:
progress_callback(idx + 1, total_files, png_file.name)
# Check if PNG is valid
if self.is_png_valid(png_file):
# Store the relative path from output_path
try:
relative_png = png_file.relative_to(output_path)
existing_exports.add(relative_png)
except ValueError:
pass
else:
# File exists but is broken
broken_files.append(png_file)
return existing_exports, broken_files
def get_expected_output_path(self, npy_file, output_path, folder_path_obj):
"""Get the expected output PNG path for a given .npy file"""
try:
relative_path = npy_file.relative_to(folder_path_obj)
except ValueError:
relative_path = Path(npy_file.name)
return output_path / relative_path.parent / (npy_file.stem + ".png")
def normalize_image_for_export(self, img_array, filename):
"""Convert image array to PIL Image while preserving data quality (lossless)
Returns PIL Image with original data type preserved where possible"""
# Convert BGR to RGB if it's a 3-channel image
if len(img_array.shape) == 3 and img_array.shape[2] == 3:
img_array = img_array[:, :, ::-1]
# Handle different data types - preserve original quality
try:
if len(img_array.shape) == 3:
# Multi-channel image (RGB, etc.)
if img_array.dtype == np.uint8:
# 8-bit RGB - lossless
return Image.fromarray(img_array, mode='RGB')
elif img_array.dtype == np.uint16:
# 16-bit RGB - Note: PIL's PNG plugin may convert to 8-bit
# For true 16-bit RGB PNG, consider using imageio library
# For now, preserving as uint16 (may be converted on save)
return Image.fromarray(img_array, mode='RGB')
elif img_array.dtype == np.uint32:
# 32-bit -> convert to 16-bit to preserve more precision than 8-bit
# Scale to 16-bit range (0-65535)
max_val = np.iinfo(np.uint32).max
img_array = (img_array / (max_val / 65535.0)).astype(np.uint16)
return Image.fromarray(img_array, mode='RGB')
elif img_array.dtype in [np.float32, np.float64]:
# Float -> convert to 16-bit to preserve precision
# Normalize to 0-1, then scale to 16-bit
img_min = np.min(img_array)
img_max = np.max(img_array)
if img_max > img_min:
normalized = (img_array - img_min) / (img_max - img_min)
else:
normalized = np.zeros_like(img_array)
# Scale to 16-bit range
img_array = (normalized * 65535.0).astype(np.uint16)
return Image.fromarray(img_array, mode='RGB')
else:
# Unknown type - try direct conversion
return Image.fromarray(img_array.astype(np.uint8), mode='RGB')
else:
# Single channel image (grayscale or depth)
if img_array.dtype == np.uint8:
# 8-bit grayscale - lossless
return Image.fromarray(img_array, mode='L')
elif img_array.dtype == np.uint16:
# 16-bit grayscale - scale to full range for better display, but preserve original values
# Check if this is a depth image by filename
is_depth = 'depth' in filename.lower()
if is_depth:
# For depth images, scale to full 16-bit range for better visibility
# Store original min/max in image info for lossless restoration
valid_mask = (img_array > 0) & np.isfinite(img_array.astype(np.float32))
if np.any(valid_mask):
original_min = int(np.min(img_array[valid_mask]))
original_max = int(np.max(img_array[valid_mask]))
if original_max > original_min:
# Scale to full 16-bit range (1-65535) for better visibility
# Use INTEGER arithmetic only for perfect lossless conversion
# Create a copy to work with
scaled = img_array.astype(np.uint32).copy() # Use uint32 for intermediate calculations
# Calculate range
range_size = original_max - original_min
# Only scale valid (non-zero) values using integer arithmetic
# Scale: ((value - min) * 65534 + range_size/2) // range_size + 1
# This uses integer division with rounding (adding half before dividing)
# This ensures perfect reversibility
scaled[valid_mask] = (((scaled[valid_mask].astype(np.uint32) - original_min) * 65534 +
(range_size // 2)) // range_size + 1).astype(np.uint16)
# Preserve original zeros (don't scale them)
# Zeros stay as 0, valid values are now in range 1-65535
scaled[img_array == 0] = 0
scaled = scaled.astype(np.uint16)
# Create image and store metadata for restoration
img = Image.fromarray(scaled, mode='I;16')
# Store original range in PNG text metadata for lossless restoration
# Store in info dict - will be converted to PngInfo when saving
img.info['npy_original_min'] = str(original_min)
img.info['npy_original_max'] = str(original_max)
img.info['npy_scaled'] = 'true'
return img
else:
# All values are the same
return Image.fromarray(img_array, mode='I;16')
else:
# No valid values
return Image.fromarray(img_array, mode='I;16')
else:
# Regular 16-bit grayscale - lossless (no scaling)
return Image.fromarray(img_array, mode='I;16')
elif img_array.dtype == np.uint32:
# 32-bit -> convert to 16-bit to preserve more precision than 8-bit
max_val = np.iinfo(np.uint32).max
img_array = (img_array / (max_val / 65535.0)).astype(np.uint16)
return Image.fromarray(img_array, mode='I;16')
elif img_array.dtype in [np.float32, np.float64]:
# Float -> convert to 16-bit to preserve precision
# Handle invalid values (NaN, Inf)
valid_mask = np.isfinite(img_array)
if np.any(valid_mask):
img_min = np.min(img_array[valid_mask])
img_max = np.max(img_array[valid_mask])
if img_max > img_min:
normalized = (img_array - img_min) / (img_max - img_min)
normalized = np.clip(normalized, 0, 1)
else:
normalized = np.zeros_like(img_array)
# Set invalid pixels to 0
normalized[~valid_mask] = 0
# Scale to 16-bit range
img_array = (normalized * 65535.0).astype(np.uint16)
else:
# No valid values
img_array = np.zeros_like(img_array, dtype=np.uint16)
return Image.fromarray(img_array, mode='I;16')
else:
# Unknown type - try direct conversion to uint8
return Image.fromarray(img_array.astype(np.uint8), mode='L')
except Exception as e:
# Fallback: convert to uint8 if anything fails
try:
if len(img_array.shape) == 3:
return Image.fromarray(img_array.astype(np.uint8), mode='RGB')
else:
return Image.fromarray(img_array.astype(np.uint8), mode='L')
except:
raise e
def export_folder(self):
"""Export all .npy files in current folder as PNG images"""
# Ask for output directory
output_dir = filedialog.askdirectory(
title="Select output directory for PNG exports",
initialdir=self.folder_path
)
if not output_dir:
return
# Ask for subfolder name
subfolder_name = simpledialog.askstring(
"Subfolder Name",
"Enter name for the export subfolder:",
initialvalue="png_exports"
)
if not subfolder_name:
return
# Create the output subfolder
output_path = Path(output_dir) / subfolder_name
try:
output_path.mkdir(parents=True, exist_ok=True)
except Exception as e:
self.show_status(f"⚠ Error creating folder: {e}", 3000, "red")
return
# Convert folder_path to Path for easier manipulation
folder_path_obj = Path(self.folder_path)
# Scan existing exports
progress_window = tk.Toplevel(self.root)
progress_window.title("Scanning Exports")
progress_window.geometry("500x150")
progress_window.transient(self.root)
progress_window.grab_set()
scan_label = tk.Label(
progress_window,
text="Scanning existing exported files...",
font=("Arial", 12),
pady=10
)
scan_label.pack()
scan_progress_bar = ttk.Progressbar(
progress_window,
length=400,
mode='determinate',
maximum=100 # Will be updated when we know the total
)
scan_progress_bar.pack(pady=10)
scan_status_label = tk.Label(
progress_window,
text="",
font=("Arial", 10),
fg="gray"
)
scan_status_label.pack()
# Force window to appear
progress_window.update()
# Progress callback for scanning
def update_scan_progress(current, total, filename):
if total > 0:
scan_progress_bar['maximum'] = total
scan_progress_bar['value'] = current
scan_status_label.config(text=f"Checking: {filename}")
else:
scan_status_label.config(text="No existing files found")
progress_window.update()
existing_exports, broken_files = self.scan_existing_exports(
output_path, folder_path_obj, progress_callback=update_scan_progress
)
# Delete broken files
for broken_file in broken_files:
try:
broken_file.unlink()
except Exception as e:
print(f"Warning: Could not delete broken file {broken_file}: {e}")
# Close scanning window
progress_window.destroy()
# Calculate which files need to be exported
files_to_export = []
skipped_count = 0
for npy_file in self.npy_files:
expected_output = self.get_expected_output_path(npy_file, output_path, folder_path_obj)
expected_relative = expected_output.relative_to(output_path)
if expected_relative in existing_exports:
skipped_count += 1
else:
files_to_export.append(npy_file)
# Show info about what will be done
if skipped_count > 0 or broken_files:
info_msg = f"Found {skipped_count} already exported files"
if broken_files:
info_msg += f", removed {len(broken_files)} broken file(s)"
info_msg += f". Will export {len(files_to_export)} remaining files."
self.show_status(info_msg, 4000, "blue")
print(info_msg)
if not files_to_export:
self.show_status(f"✓ All {len(self.npy_files)} files already exported!", 3000, "green")
return
# Create a progress window
progress_window = tk.Toplevel(self.root)
progress_window.title("Exporting Images")
progress_window.geometry("500x150")
progress_window.transient(self.root)
progress_window.grab_set()
progress_label = tk.Label(
progress_window,
text="Starting export...",
font=("Arial", 12),
pady=20
)
progress_label.pack()
progress_bar = ttk.Progressbar(
progress_window,
length=400,
mode='determinate',
maximum=len(files_to_export)
)
progress_bar.pack(pady=10)
status_label = tk.Label(
progress_window,
text="",
font=("Arial", 10),
fg="gray"
)
status_label.pack()
# Force window to appear
progress_window.update()
# Export each file
exported_count = skipped_count # Start with already exported count
failed_files = []
for idx, npy_file in enumerate(files_to_export):
try:
# Update progress
progress_label.config(text=f"Exporting {idx + 1} of {len(files_to_export)}...")
status_label.config(text=f"File: {npy_file.name}")
progress_bar['value'] = idx + 1
progress_window.update()
# Load numpy array
img_array = np.load(npy_file)
# Normalize and convert to PIL Image (handles depth images properly)
im = self.normalize_image_for_export(img_array, npy_file.name)
# Get expected output path
output_filepath = self.get_expected_output_path(npy_file, output_path, folder_path_obj)
# Create parent directories if they don't exist
output_filepath.parent.mkdir(parents=True, exist_ok=True)
# Save as PNG with metadata if present
pnginfo = None
if hasattr(im, 'info') and any(key.startswith('npy_') for key in im.info.keys()):
# Create PngInfo object to preserve metadata
pnginfo = PngImagePlugin.PngInfo()
for key, value in im.info.items():
if key.startswith('npy_'):
pnginfo.add_text(key, str(value))
if pnginfo:
im.save(output_filepath, "PNG", pnginfo=pnginfo)
else:
im.save(output_filepath, "PNG")
exported_count += 1
except Exception as e:
failed_files.append((npy_file.name, str(e)))
# Close progress window
progress_window.destroy()
# Show summary
if failed_files:
summary_msg = f"✓ Exported {exported_count}/{len(self.npy_files)} images"
if skipped_count > 0:
summary_msg += f" ({skipped_count} skipped, {len(files_to_export) - len(failed_files)} new)"
summary_msg += f"\n⚠ {len(failed_files)} failed"
self.show_status(summary_msg, 5000, "orange")
print(f"\n{summary_msg}")
for filename, error in failed_files:
print(f" Failed: {filename} - {error}")
else:
summary_msg = f"✓ Successfully exported {exported_count}/{len(self.npy_files)} images to PNG!"
if skipped_count > 0:
summary_msg += f" ({skipped_count} were already exported, {len(files_to_export)} new)"
self.show_status(summary_msg, 4000, "green")
print(summary_msg)
if __name__ == "__main__":
try:
if len(sys.argv) > 2:
cmd = sys.argv[0] if len(sys.argv) >= 1 else "npy_viewer.py"
print(f"Usage: python3 {cmd} [folder_path]")
sys.exit(1)
# Get folder path from command line or None (will prompt dialog)
folder_path = None
if len(sys.argv) >= 2:
folder_path = sys.argv[1]
if not Path(folder_path).exists():
print(f"Error: Folder '{folder_path}' does not exist")
sys.exit(2)
root = tk.Tk()
viewer = ImageViewer(root, folder_path)
root.mainloop()
except KeyboardInterrupt:
print("Keyboard interrupt detected. Exiting...")
sys.exit(0)
#!/usr/bin/env python3
"""
Script to combine PNG images from folders into MP4 videos.
Creates separate videos for each image type (pose, color, depth) if mixed in folders.
Organizes videos into type-specific folders.
"""
import os
from pathlib import Path
import cv2
import re
from collections import defaultdict
# ============================================================================
# CONFIGURATION - Easy to change framerate here
# ============================================================================
FRAMERATE = 30 # Change this value to adjust video framerate (e.g., 24, 30, 60)
# ============================================================================
# Paths
# ============================================================================
SOURCE_FOLDER = r"C:\Users\kezzyhko\Downloads\B5G dataset images from humanoid robot\png_exports"
OUTPUT_FOLDER = r"C:\Users\kezzyhko\Downloads\B5G dataset images from humanoid robot\videos"
# Image type prefixes to detect and separate
IMAGE_TYPES = ['pose', 'color', 'depth']
def sanitize_filename(path_str):
"""Convert a path to a valid filename by replacing backslashes with dashes and spaces with underscores."""
# Replace backslashes with dashes
filename = path_str.replace('\\', '-')
# Replace colons with dashes (Windows paths have C:)
filename = filename.replace(':', '-')
# Replace spaces with underscores
filename = filename.replace(' ', '_')
# Remove any double dashes
filename = re.sub(r'-+', '-', filename)
# Remove any double underscores
filename = re.sub(r'_+', '_', filename)
# Strip leading/trailing dashes and underscores
filename = filename.strip('-_')
return filename
def get_image_type(filename):
"""Determine the image type based on filename prefix."""
filename_lower = filename.lower()
for img_type in IMAGE_TYPES:
if filename_lower.startswith(img_type + '_'):
return img_type
return None
def group_images_by_type(folder_path):
"""Group image files by their type prefix (pose, color, depth)."""
image_extensions = {'.png', '.jpg', '.jpeg', '.bmp', '.tiff', '.tif'}
grouped = defaultdict(list)
untyped = []
for file_path in Path(folder_path).iterdir():
if file_path.is_file() and file_path.suffix.lower() in image_extensions:
img_type = get_image_type(file_path.name)
if img_type:
grouped[img_type].append(file_path)
else:
untyped.append(file_path)
# Sort each group
for img_type in grouped:
grouped[img_type] = sorted(grouped[img_type])
untyped = sorted(untyped)
return grouped, untyped
def create_video_from_images(image_files, output_path, framerate=30):
"""Create a video from a list of image files."""
if not image_files:
return False
print(f" Processing {len(image_files)} images...")
# Read first image to get dimensions
first_image = cv2.imread(str(image_files[0]))
if first_image is None:
print(f" Error: Could not read first image {image_files[0]}")
return False
height, width, channels = first_image.shape
# Define codec and create VideoWriter
fourcc = cv2.VideoWriter_fourcc(*'mp4v')
video_writer = cv2.VideoWriter(str(output_path), fourcc, framerate, (width, height))
if not video_writer.isOpened():
print(f" Error: Could not open video writer for {output_path}")
return False
# Write all images to video
for i, image_path in enumerate(image_files):
img = cv2.imread(str(image_path))
if img is None:
print(f" Warning: Could not read image {image_path}, skipping...")
continue
# Resize image if dimensions don't match (shouldn't happen, but just in case)
if img.shape[:2] != (height, width):
img = cv2.resize(img, (width, height))
video_writer.write(img)
if (i + 1) % 100 == 0:
print(f" Processed {i + 1}/{len(image_files)} images...")
video_writer.release()
print(f" ✓ Created video: {output_path}")
return True
def main():
"""Main function to process all folders and create videos."""
source_path = Path(SOURCE_FOLDER)
output_path = Path(OUTPUT_FOLDER)
# Create output folder if it doesn't exist
output_path.mkdir(parents=True, exist_ok=True)
if not source_path.exists():
print(f"Error: Source folder does not exist: {SOURCE_FOLDER}")
return
print(f"Source folder: {SOURCE_FOLDER}")
print(f"Output folder: {OUTPUT_FOLDER}")
print(f"Framerate: {FRAMERATE} fps")
print("=" * 80)
# Find all folders containing images
folders_to_process = []
# Walk through the source folder
for root, dirs, files in os.walk(source_path):
# Check if this folder has any image files
image_files = [f for f in files if Path(f).suffix.lower() in {'.png', '.jpg', '.jpeg', '.bmp', '.tiff', '.tif'}]
if image_files:
folders_to_process.append(Path(root))
if not folders_to_process:
print("No folders with images found!")
return
print(f"Found {len(folders_to_process)} folders with images to process\n")
# Process each folder
successful = 0
failed = 0
for folder_path in folders_to_process:
# Get relative path from source folder
try:
relative_path = folder_path.relative_to(source_path)
except ValueError:
# If folder_path is not relative to source_path, use the full path
relative_path = folder_path
# Group images by type
grouped_images, untyped_images = group_images_by_type(folder_path)
# Create base filename (without type prefix)
path_str = str(relative_path)
base_filename = sanitize_filename(path_str)
print(f"\nProcessing: {relative_path}")
# If we have typed images, create separate videos for each type
# Only create videos for types that actually have images in this folder
if grouped_images:
for img_type, image_files in sorted(grouped_images.items()):
# Only process if this type has images
if not image_files:
continue
# Create output filename with type prefix: type-base_filename.mp4
output_file = output_path / f"{img_type}-{base_filename}.mp4"
# Skip if video already exists
if output_file.exists():
print(f" Skipping {img_type} video (already exists): {output_file.name}")
successful += 1
continue
print(f" Creating {img_type} video: {output_file.name}")
if create_video_from_images(image_files, output_file, FRAMERATE):
successful += 1
else:
failed += 1
# If we have untyped images, create a video in the root output folder
if untyped_images:
output_file = output_path / f"{base_filename}.mp4"
# Skip if video already exists
if output_file.exists():
print(f" Skipping untyped video (already exists): {output_file.name}")
successful += 1
else:
print(f" Creating video for untyped images: {output_file.name}")
if create_video_from_images(untyped_images, output_file, FRAMERATE):
successful += 1
else:
failed += 1
# If no images were found (shouldn't happen, but just in case)
if not grouped_images and not untyped_images:
print(f" No images found in {folder_path}")
failed += 1
print("\n" + "=" * 80)
print(f"Processing complete!")
print(f" Successful: {successful}")
print(f" Failed: {failed}")
print(f" Total folders processed: {len(folders_to_process)}")
if __name__ == "__main__":
main()
@kezzyhko
Copy link
Author

kezzyhko commented Dec 3, 2025

image

@kezzyhko
Copy link
Author

kezzyhko commented Dec 3, 2025

Fully vibe-coded for one specific purpose. Might not work great in other cases

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment